Merge remote-tracking branch 'origin/main' into implement-planning/speckits

This commit is contained in:
SuperComboGamer
2025-12-17 21:40:42 -05:00
126 changed files with 16113 additions and 1069 deletions

3
.gitignore vendored
View File

@@ -17,8 +17,9 @@ out/
/.automaker/* /.automaker/*
/.automaker/ /.automaker/
/logs .worktrees/
/logs
# Logs # Logs
logs/ logs/
*.log *.log

View File

@@ -1,7 +1,9 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007; const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === "true"; const reuseServer = process.env.TEST_REUSE_SERVER === "true";
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
@@ -25,15 +27,33 @@ export default defineConfig({
...(reuseServer ...(reuseServer
? {} ? {}
: { : {
webServer: { webServer: [
command: `npx next dev -p ${port}`, // Backend server - runs with mock agent enabled in CI
url: `http://localhost:${port}`, {
reuseExistingServer: !process.env.CI, command: `cd ../server && npm run dev`,
timeout: 120000, url: `http://localhost:${serverPort}/api/health`,
env: { reuseExistingServer: true,
...process.env, timeout: 60000,
NEXT_PUBLIC_SKIP_SETUP: "true", env: {
...process.env,
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false",
// Allow access to test directories and common project paths
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
},
}, },
}, // Frontend Next.js server
{
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: true,
timeout: 120000,
env: {
...process.env,
NEXT_PUBLIC_SKIP_SETUP: "true",
},
},
],
}), }),
}); });

Binary file not shown.

View File

@@ -13,6 +13,9 @@
@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 *)); @custom-variant red (&:is(.red *));
@custom-variant cream (&:is(.cream *));
@custom-variant sunset (&:is(.sunset *));
@custom-variant gray (&:is(.gray *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
@@ -1220,6 +1223,252 @@
--running-indicator-text: oklch(0.6 0.23 25); --running-indicator-text: oklch(0.6 0.23 25);
} }
.cream {
/* Cream Theme - Warm, soft, easy on the eyes */
--background: oklch(0.95 0.01 70); /* Warm cream background */
--background-50: oklch(0.95 0.01 70 / 0.5);
--background-80: oklch(0.95 0.01 70 / 0.8);
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
--card-foreground: oklch(0.25 0.02 60);
--popover: oklch(0.97 0.008 70);
--popover-foreground: oklch(0.25 0.02 60);
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
--primary-foreground: oklch(0.98 0.005 70);
--brand-400: oklch(0.55 0.12 45);
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
--brand-600: oklch(0.45 0.13 45);
--secondary: oklch(0.88 0.02 70);
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.9 0.015 70);
--muted-foreground: oklch(0.45 0.02 60);
--accent: oklch(0.85 0.025 70);
--accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.55 0.22 25); /* Warm red */
--border: oklch(0.85 0.015 70);
--border-glass: oklch(0.5 0.12 45 / 0.2);
--input: oklch(0.98 0.005 70);
--ring: oklch(0.5 0.12 45);
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
--chart-3: oklch(0.6 0.12 100); /* Olive */
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
--chart-5: oklch(0.65 0.1 80); /* Golden */
--sidebar: oklch(0.93 0.012 70);
--sidebar-foreground: oklch(0.25 0.02 60);
--sidebar-primary: oklch(0.5 0.12 45);
--sidebar-primary-foreground: oklch(0.98 0.005 70);
--sidebar-accent: oklch(0.88 0.02 70);
--sidebar-accent-foreground: oklch(0.25 0.02 60);
--sidebar-border: oklch(0.85 0.015 70);
--sidebar-ring: oklch(0.5 0.12 45);
/* Action button colors - Warm earth tones */
--action-view: oklch(0.5 0.12 45); /* Terracotta */
--action-view-hover: oklch(0.45 0.13 45);
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
--action-followup-hover: oklch(0.5 0.16 35);
--action-commit: oklch(0.55 0.12 130); /* Sage green */
--action-commit-hover: oklch(0.5 0.13 130);
--action-verify: oklch(0.55 0.12 130); /* Sage green */
--action-verify-hover: oklch(0.5 0.13 130);
/* Running indicator - Terracotta */
--running-indicator: oklch(0.5 0.12 45);
--running-indicator-text: oklch(0.55 0.12 45);
/* Status colors - Cream theme */
--status-success: oklch(0.55 0.15 130);
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
--status-warning: oklch(0.6 0.15 70);
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.5 0.15 230);
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.02 60);
--status-in-progress: oklch(0.6 0.15 70);
--status-waiting: oklch(0.58 0.13 50);
}
.sunset {
/* Sunset Theme - Mellow oranges and soft purples */
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
--background-50: oklch(0.15 0.02 280 / 0.5);
--background-80: oklch(0.15 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 80); /* Warm white */
--foreground-secondary: oklch(0.75 0.02 60);
--foreground-muted: oklch(0.6 0.02 60);
--card: oklch(0.2 0.025 280);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.02 280);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
--primary-foreground: oklch(0.15 0.02 280);
--brand-400: oklch(0.72 0.17 45);
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
--brand-600: oklch(0.64 0.19 42);
--secondary: oklch(0.25 0.03 280);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.27 0.03 280);
--muted-foreground: oklch(0.6 0.02 60);
--accent: oklch(0.35 0.04 310);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.04 280);
--border-glass: oklch(0.68 0.18 45 / 0.3);
--input: oklch(0.2 0.025 280);
--ring: oklch(0.68 0.18 45);
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
--sidebar: oklch(0.13 0.015 280);
--sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.68 0.18 45);
--sidebar-primary-foreground: oklch(0.15 0.02 280);
--sidebar-accent: oklch(0.25 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 80);
--sidebar-border: oklch(0.32 0.04 280);
--sidebar-ring: oklch(0.68 0.18 45);
/* Action button colors - Mellow sunset palette */
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
--action-view-hover: oklch(0.64 0.19 42);
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
--action-followup-hover: oklch(0.7 0.17 340);
--action-commit: oklch(0.65 0.16 140); /* Soft green */
--action-commit-hover: oklch(0.6 0.17 140);
--action-verify: oklch(0.65 0.16 140); /* Soft green */
--action-verify-hover: oklch(0.6 0.17 140);
/* Running indicator - Mellow orange */
--running-indicator: oklch(0.68 0.18 45);
--running-indicator-text: oklch(0.72 0.17 45);
/* Status colors - Sunset theme */
--status-success: oklch(0.65 0.16 140);
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
--status-warning: oklch(0.78 0.18 70);
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
--status-error: oklch(0.65 0.2 25);
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
--status-info: oklch(0.75 0.16 340);
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
--status-backlog: oklch(0.65 0.02 280);
--status-in-progress: oklch(0.78 0.18 70);
--status-waiting: oklch(0.72 0.17 60);
}
.gray {
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
--background-50: oklch(0.2 0.005 250 / 0.5);
--background-80: oklch(0.2 0.005 250 / 0.8);
--foreground: oklch(0.9 0.005 250); /* Light gray */
--foreground-secondary: oklch(0.65 0.005 250);
--foreground-muted: oklch(0.5 0.005 250);
--card: oklch(0.24 0.005 250);
--card-foreground: oklch(0.9 0.005 250);
--popover: oklch(0.22 0.005 250);
--popover-foreground: oklch(0.9 0.005 250);
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
--primary-foreground: oklch(0.95 0.005 250);
--brand-400: oklch(0.65 0.08 250);
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
--brand-600: oklch(0.55 0.09 250);
--secondary: oklch(0.28 0.005 250);
--secondary-foreground: oklch(0.9 0.005 250);
--muted: oklch(0.3 0.005 250);
--muted-foreground: oklch(0.6 0.005 250);
--accent: oklch(0.35 0.01 250);
--accent-foreground: oklch(0.9 0.005 250);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.005 250);
--border-glass: oklch(0.6 0.08 250 / 0.2);
--input: oklch(0.24 0.005 250);
--ring: oklch(0.6 0.08 250);
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
--chart-2: oklch(0.65 0.1 210); /* Cyan */
--chart-3: oklch(0.7 0.12 160); /* Teal */
--chart-4: oklch(0.65 0.1 280); /* Purple */
--chart-5: oklch(0.7 0.08 300); /* Violet */
--sidebar: oklch(0.18 0.005 250);
--sidebar-foreground: oklch(0.9 0.005 250);
--sidebar-primary: oklch(0.6 0.08 250);
--sidebar-primary-foreground: oklch(0.95 0.005 250);
--sidebar-accent: oklch(0.28 0.005 250);
--sidebar-accent-foreground: oklch(0.9 0.005 250);
--sidebar-border: oklch(0.32 0.005 250);
--sidebar-ring: oklch(0.6 0.08 250);
/* Action button colors - Subtle modern colors */
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
--action-view-hover: oklch(0.55 0.09 250);
--action-followup: oklch(0.65 0.1 210); /* Cyan */
--action-followup-hover: oklch(0.6 0.11 210);
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
--action-commit-hover: oklch(0.6 0.13 150);
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
--action-verify-hover: oklch(0.6 0.13 150);
/* Running indicator - Blue-gray */
--running-indicator: oklch(0.6 0.08 250);
--running-indicator-text: oklch(0.65 0.08 250);
/* Status colors - Gray theme */
--status-success: oklch(0.65 0.12 150);
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
--status-warning: oklch(0.7 0.15 70);
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
--status-error: oklch(0.6 0.2 25);
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
--status-info: oklch(0.65 0.1 210);
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
--status-backlog: oklch(0.6 0.005 250);
--status-in-progress: oklch(0.7 0.15 70);
--status-waiting: oklch(0.68 0.1 220);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -1255,12 +1504,12 @@
} }
/* Custom scrollbar for dark themes */ /* Custom scrollbar for dark themes */
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar { :is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
} }
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track { :is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red, .sunset, .gray) ::-webkit-scrollbar-track {
background: var(--muted); background: var(--muted);
} }
@@ -1296,6 +1545,62 @@
background: oklch(0.15 0.05 25); background: oklch(0.15 0.05 25);
} }
/* Cream theme scrollbar */
.cream ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.cream ::-webkit-scrollbar-thumb,
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
border-radius: 4px;
}
.cream ::-webkit-scrollbar-thumb:hover,
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}
.cream ::-webkit-scrollbar-track,
.cream .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.9 0.015 70);
}
/* Sunset theme scrollbar */
.sunset ::-webkit-scrollbar-thumb,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
border-radius: 4px;
}
.sunset ::-webkit-scrollbar-thumb:hover,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}
.sunset ::-webkit-scrollbar-track,
.sunset .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.18 0.03 280);
}
/* Gray theme scrollbar */
.gray ::-webkit-scrollbar-thumb,
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
border-radius: 4px;
}
.gray ::-webkit-scrollbar-thumb:hover,
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}
.gray ::-webkit-scrollbar-track,
.gray .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.25 0.005 250);
}
/* Always visible scrollbar for file diffs and code blocks */ /* Always visible scrollbar for file diffs and code blocks */
.scrollbar-visible { .scrollbar-visible {
overflow-y: auto !important; overflow-y: auto !important;

View File

@@ -133,10 +133,10 @@ function HomeContent() {
// Apply theme class to document (uses effective theme - preview, 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( const themeClasses = [
"dark", "dark",
"retro",
"light", "light",
"retro",
"dracula", "dracula",
"nord", "nord",
"monokai", "monokai",
@@ -146,43 +146,22 @@ function HomeContent() {
"catppuccin", "catppuccin",
"onedark", "onedark",
"synthwave", "synthwave",
"red" "red",
); "cream",
"sunset",
"gray",
];
if (effectiveTheme === "dark") { // Remove all theme classes
root.classList.add("dark"); root.classList.remove(...themeClasses);
} else if (effectiveTheme === "retro") {
root.classList.add("retro"); // Apply the effective theme
} else if (effectiveTheme === "dracula") { if (themeClasses.includes(effectiveTheme)) {
root.classList.add("dracula"); root.classList.add(effectiveTheme);
} else if (effectiveTheme === "nord") {
root.classList.add("nord");
} else if (effectiveTheme === "monokai") {
root.classList.add("monokai");
} else if (effectiveTheme === "tokyonight") {
root.classList.add("tokyonight");
} else if (effectiveTheme === "solarized") {
root.classList.add("solarized");
} else if (effectiveTheme === "gruvbox") {
root.classList.add("gruvbox");
} else if (effectiveTheme === "catppuccin") {
root.classList.add("catppuccin");
} else if (effectiveTheme === "onedark") {
root.classList.add("onedark");
} else if (effectiveTheme === "synthwave") {
root.classList.add("synthwave");
} else if (effectiveTheme === "red") {
root.classList.add("red");
} else if (effectiveTheme === "light") {
root.classList.add("light");
} else if (effectiveTheme === "system") { } else if (effectiveTheme === "system") {
// System theme // System theme - detect OS preference
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (isDark) { root.classList.add(isDark ? "dark" : "light");
root.classList.add("dark");
} else {
root.classList.add("light");
}
} }
}, [effectiveTheme, previewTheme, currentProject, theme]); }, [effectiveTheme, previewTheme, currentProject, theme]);

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { import {
FolderOpen, FolderOpen,
Folder, Folder,
@@ -9,6 +9,8 @@ import {
ArrowLeft, ArrowLeft,
HardDrive, HardDrive,
CornerDownLeft, CornerDownLeft,
Clock,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, Dialog,
@@ -45,6 +47,44 @@ interface FileBrowserDialogProps {
initialPath?: string; initialPath?: string;
} }
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
const MAX_RECENT_FOLDERS = 5;
function getRecentFolders(): string[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
function addRecentFolder(path: string): void {
if (typeof window === "undefined") return;
try {
const recent = getRecentFolders();
// Remove if already exists, then add to front
const filtered = recent.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
} catch {
// Ignore localStorage errors
}
}
function removeRecentFolder(path: string): string[] {
if (typeof window === "undefined") return [];
try {
const recent = getRecentFolders();
const updated = recent.filter((p) => p !== path);
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
return updated;
} catch {
return [];
}
}
export function FileBrowserDialog({ export function FileBrowserDialog({
open, open,
onOpenChange, onOpenChange,
@@ -61,8 +101,26 @@ export function FileBrowserDialog({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [warning, setWarning] = useState(""); const [warning, setWarning] = useState("");
const [recentFolders, setRecentFolders] = useState<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null); const pathInputRef = useRef<HTMLInputElement>(null);
// Load recent folders when dialog opens
useEffect(() => {
if (open) {
setRecentFolders(getRecentFolders());
}
}, [open]);
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
}, []);
const handleSelectRecent = useCallback((path: string) => {
browseDirectory(path);
}, []);
const browseDirectory = async (dirPath?: string) => { const browseDirectory = async (dirPath?: string) => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -153,27 +211,34 @@ export function FileBrowserDialog({
const handleSelect = () => { const handleSelect = () => {
if (currentPath) { if (currentPath) {
addRecentFolder(currentPath);
onSelect(currentPath); onSelect(currentPath);
onOpenChange(false); onOpenChange(false);
} }
}; };
// Helper to get folder name from path
const getFolderName = (path: string) => {
const parts = path.split(/[/\\]/).filter(Boolean);
return parts[parts.length - 1] || path;
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> <DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
<DialogHeader className="pb-2"> <DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-5 h-5 text-brand-500" /> <FolderOpen className="w-4 h-4 text-brand-500" />
{title} {title}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground text-xs">
{description} {description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2"> <div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
{/* Direct path input */} {/* Direct path input */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Input <Input
ref={pathInputRef} ref={pathInputRef}
type="text" type="text"
@@ -181,7 +246,7 @@ export function FileBrowserDialog({
value={pathInput} value={pathInput}
onChange={(e) => setPathInput(e.target.value)} onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown} onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm" className="flex-1 font-mono text-xs h-8"
data-testid="path-input" data-testid="path-input"
disabled={loading} disabled={loading}
/> />
@@ -191,16 +256,46 @@ export function FileBrowserDialog({
onClick={handleGoToPath} onClick={handleGoToPath}
disabled={loading || !pathInput.trim()} disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button" data-testid="go-to-path-button"
className="h-8 px-2"
> >
<CornerDownLeft className="w-4 h-4 mr-1" /> <CornerDownLeft className="w-3.5 h-3.5 mr-1" />
Go Go
</Button> </Button>
</div> </div>
{/* Recent folders */}
{recentFolders.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
<Clock className="w-3 h-3" />
<span>Recent:</span>
</div>
{recentFolders.map((folder) => (
<button
key={folder}
onClick={() => handleSelectRecent(folder)}
className="group flex items-center gap-1 h-6 px-2 text-xs bg-sidebar-accent/20 hover:bg-sidebar-accent/40 rounded border border-sidebar-border transition-colors"
disabled={loading}
title={folder}
>
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
<button
onClick={(e) => handleRemoveRecent(e, folder)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
title="Remove from recent"
>
<X className="w-3 h-3" />
</button>
</button>
))}
</div>
)}
{/* Drives selector (Windows only) */} {/* Drives selector (Windows only) */}
{drives.length > 0 && ( {drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border"> <div className="flex flex-wrap gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2"> <div className="flex items-center gap-1 text-xs text-muted-foreground mr-1">
<HardDrive className="w-3 h-3" /> <HardDrive className="w-3 h-3" />
<span>Drives:</span> <span>Drives:</span>
</div> </div>
@@ -212,7 +307,7 @@ export function FileBrowserDialog({
} }
size="sm" size="sm"
onClick={() => handleSelectDrive(drive)} onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs" className="h-6 px-2 text-xs"
disabled={loading} disabled={loading}
> >
{drive.replace("\\", "")} {drive.replace("\\", "")}
@@ -222,57 +317,57 @@ export function FileBrowserDialog({
)} )}
{/* Current path breadcrumb */} {/* Current path breadcrumb */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border"> <div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleGoHome} onClick={handleGoHome}
className="h-7 px-2" className="h-6 px-1.5"
disabled={loading} disabled={loading}
> >
<Home className="w-4 h-4" /> <Home className="w-3.5 h-3.5" />
</Button> </Button>
{parentPath && ( {parentPath && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleGoToParent} onClick={handleGoToParent}
className="h-7 px-2" className="h-6 px-1.5"
disabled={loading} disabled={loading}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-3.5 h-3.5" />
</Button> </Button>
)} )}
<div className="flex-1 font-mono text-sm truncate text-muted-foreground"> <div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || "Loading..."} {currentPath || "Loading..."}
</div> </div>
</div> </div>
{/* Directory list */} {/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg"> <div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && ( {loading && (
<div className="flex items-center justify-center h-full p-8"> <div className="flex items-center justify-center h-full p-4">
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground">
Loading directories... Loading directories...
</div> </div>
</div> </div>
)} )}
{error && ( {error && (
<div className="flex items-center justify-center h-full p-8"> <div className="flex items-center justify-center h-full p-4">
<div className="text-sm text-destructive">{error}</div> <div className="text-xs text-destructive">{error}</div>
</div> </div>
)} )}
{warning && ( {warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2"> <div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
<div className="text-sm text-yellow-500">{warning}</div> <div className="text-xs text-yellow-500">{warning}</div>
</div> </div>
)} )}
{!loading && !error && !warning && directories.length === 0 && ( {!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8"> <div className="flex items-center justify-center h-full p-4">
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground">
No subdirectories found No subdirectories found
</div> </div>
</div> </div>
@@ -284,29 +379,29 @@ export function FileBrowserDialog({
<button <button
key={dir.path} key={dir.path}
onClick={() => handleSelectDirectory(dir)} onClick={() => handleSelectDirectory(dir)}
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group" className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-sidebar-accent/10 transition-colors text-left group"
> >
<Folder className="w-5 h-5 text-brand-500 shrink-0" /> <Folder className="w-4 h-4 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-sm">{dir.name}</span> <span className="flex-1 truncate text-xs">{dir.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> <ChevronRight className="w-3.5 h-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-[10px] text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path. Enter or click Go to jump to a path.
</div> </div>
</div> </div>
<DialogFooter className="border-t border-border pt-4 gap-2"> <DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSelect} disabled={!currentPath || loading}> <Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-4 h-4 mr-2" /> <FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder Select Current Folder
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -0,0 +1,223 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AutocompleteOption {
value: string;
label?: string;
badge?: string;
isDefault?: boolean;
}
interface AutocompleteProps {
value: string;
onChange: (value: string) => void;
options: (string | AutocompleteOption)[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
disabled?: boolean;
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
"data-testid"?: string;
itemTestIdPrefix?: string;
}
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
if (typeof opt === "string") {
return { value: opt, label: opt };
}
return { ...opt, label: opt.label ?? opt.value };
}
export function Autocomplete({
value,
onChange,
options,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyMessage = "No results found.",
className,
disabled = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
"data-testid": testId,
itemTestIdPrefix = "option",
}: AutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const normalizedOptions = React.useMemo(
() => options.map(normalizeOption),
[options]
);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
// Filter options based on input
const filteredOptions = React.useMemo(() => {
if (!inputValue) return normalizedOptions;
const lower = inputValue.toLowerCase();
return normalizedOptions.filter(
(opt) =>
opt.value.toLowerCase().includes(lower) ||
opt.label?.toLowerCase().includes(lower)
);
}, [normalizedOptions, inputValue]);
// Check if user typed a new value that doesn't exist
const isNewValue =
allowCreate &&
inputValue.trim() &&
!normalizedOptions.some(
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
);
// Get display value
const displayValue = React.useMemo(() => {
if (!value) return null;
const found = normalizedOptions.find((opt) => opt.value === value);
return found?.label ?? value;
}, [value, normalizedOptions]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
className
)}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate">
{Icon && (
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{displayValue || placeholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 200),
}}
data-testid={testId ? `${testId}-list` : undefined}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={searchPlaceholder}
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{isNewValue ? (
<div className="py-2 px-3 text-sm">
Press enter to create{" "}
<code className="bg-muted px-1 rounded">{inputValue}</code>
</div>
) : (
emptyMessage
)}
</CommandEmpty>
<CommandGroup>
{/* Show "Create new" option if typing a new value */}
{isNewValue && (
<CommandItem
value={inputValue}
onSelect={() => {
onChange(inputValue);
setInputValue("");
setOpen(false);
}}
className="text-[var(--status-success)]"
data-testid={`${itemTestIdPrefix}-create-new`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{createLabel(inputValue)}
<span className="ml-auto text-xs text-muted-foreground">
(new)
</span>
</CommandItem>
)}
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setInputValue("");
setOpen(false);
}}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{option.label}
<Check
className={cn(
"ml-auto",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.badge && (
<span className="ml-2 text-xs text-muted-foreground">
({option.badge})
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import { GitBranch } from "lucide-react";
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
interface BranchAutocompleteProps {
value: string;
onChange: (value: string) => void;
branches: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function BranchAutocomplete({
value,
onChange,
branches,
placeholder = "Select a branch...",
className,
disabled = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
return Array.from(branchSet).map((branch) => ({
value: branch,
label: branch,
badge: branch === "main" ? "default" : undefined,
}));
}, [branches]);
return (
<Autocomplete
value={value}
onChange={onChange}
options={branchOptions}
placeholder={placeholder}
searchPlaceholder="Search or type new branch..."
emptyMessage="No branches found."
className={className}
disabled={disabled}
icon={GitBranch}
allowCreate
createLabel={(v) => `Create "${v}"`}
data-testid={testId}
itemTestIdPrefix="branch-option"
/>
);
}

View File

@@ -1,23 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react"; import { Autocomplete } from "@/components/ui/autocomplete";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CategoryAutocompleteProps { interface CategoryAutocompleteProps {
value: string; value: string;
@@ -38,81 +22,18 @@ export function CategoryAutocomplete({
disabled = false, disabled = false,
"data-testid": testId, "data-testid": testId,
}: CategoryAutocompleteProps) { }: CategoryAutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
// Listen for resize events to handle responsive behavior
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Autocomplete
<PopoverTrigger asChild> value={value}
<Button onChange={onChange}
ref={triggerRef} options={suggestions}
variant="outline" placeholder={placeholder}
role="combobox" searchPlaceholder="Search category..."
aria-expanded={open} emptyMessage="No category found."
disabled={disabled} className={className}
className={cn("w-full justify-between", className)} disabled={disabled}
data-testid={testId} data-testid={testId}
> itemTestIdPrefix="category-option"
{value />
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 200),
}}
>
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useRef } from "react";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -14,13 +14,26 @@ import {
Info, Info,
FileOutput, FileOutput,
Brain, Brain,
Eye,
Pencil,
Terminal,
Search,
ListTodo,
Layers,
X,
Filter,
Circle,
Play,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
parseLogOutput, parseLogOutput,
getLogTypeColors, getLogTypeColors,
shouldCollapseByDefault,
type LogEntry, type LogEntry,
type LogEntryType, type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser"; } from "@/lib/log-parser";
interface LogViewerProps { interface LogViewerProps {
@@ -53,6 +66,160 @@ const getLogIcon = (type: LogEntryType) => {
} }
}; };
/**
* Returns a tool-specific icon based on the tool category
*/
const getToolCategoryIcon = (category: ToolCategory | undefined) => {
switch (category) {
case "read":
return <Eye className="w-4 h-4" />;
case "edit":
return <Pencil className="w-4 h-4" />;
case "write":
return <FileOutput className="w-4 h-4" />;
case "bash":
return <Terminal className="w-4 h-4" />;
case "search":
return <Search className="w-4 h-4" />;
case "todo":
return <ListTodo className="w-4 h-4" />;
case "task":
return <Layers className="w-4 h-4" />;
default:
return <Wrench className="w-4 h-4" />;
}
};
/**
* Returns color classes for a tool category
*/
const getToolCategoryColor = (category: ToolCategory | undefined): string => {
switch (category) {
case "read":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
case "edit":
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
case "write":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
case "bash":
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
case "search":
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
case "todo":
return "text-green-400 bg-green-500/10 border-green-500/30";
case "task":
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
default:
return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
}
};
/**
* Interface for parsed todo items from TodoWrite tool
*/
interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm?: string;
}
/**
* Parses TodoWrite JSON content and extracts todo items
*/
function parseTodoContent(content: string): TodoItem[] | null {
try {
// Find the JSON object in the content
const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] };
if (!parsed.todos || !Array.isArray(parsed.todos)) return null;
return parsed.todos;
} catch {
return null;
}
}
/**
* Renders a list of todo items with status icons and colors
*/
function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
const getStatusIcon = (status: TodoItem["status"]) => {
switch (status) {
case "completed":
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case "in_progress":
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
case "pending":
return <Circle className="w-4 h-4 text-zinc-500" />;
default:
return <Circle className="w-4 h-4 text-zinc-500" />;
}
};
const getStatusColor = (status: TodoItem["status"]) => {
switch (status) {
case "completed":
return "text-emerald-300 line-through opacity-70";
case "in_progress":
return "text-amber-300";
case "pending":
return "text-zinc-400";
default:
return "text-zinc-400";
}
};
const getStatusBadge = (status: TodoItem["status"]) => {
switch (status) {
case "completed":
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
Done
</span>
);
case "in_progress":
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
In Progress
</span>
);
default:
return null;
}
};
return (
<div className="space-y-1">
{todos.map((todo, index) => (
<div
key={index}
className={cn(
"flex items-start gap-2 p-2 rounded-md transition-colors",
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
todo.status === "completed" && "bg-emerald-500/5",
todo.status === "pending" && "bg-zinc-800/30"
)}
>
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
<div className="flex-1 min-w-0">
<p className={cn("text-sm", getStatusColor(todo.status))}>
{todo.content}
</p>
{todo.status === "in_progress" && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">
{todo.activeForm}
</p>
)}
</div>
{getStatusBadge(todo.status)}
</div>
))}
</div>
);
}
interface LogEntryItemProps { interface LogEntryItemProps {
entry: LogEntry; entry: LogEntry;
isExpanded: boolean; isExpanded: boolean;
@@ -63,9 +230,54 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const colors = getLogTypeColors(entry.type); const colors = getLogTypeColors(entry.type);
const hasContent = entry.content.length > 100; const hasContent = entry.content.length > 100;
// For tool_call entries, use tool-specific styling
const isToolCall = entry.type === "tool_call";
const toolCategory = entry.metadata?.toolCategory;
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
// Check if this is a TodoWrite entry and parse the todos
const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
const parsedTodos = useMemo(() => {
if (!isTodoWrite) return null;
return parseTodoContent(entry.content);
}, [isTodoWrite, entry.content]);
// Get the appropriate icon based on entry type and tool category
const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type);
// Get collapsed preview text - prefer smart summary for tool calls
const collapsedPreview = useMemo(() => {
if (isExpanded) return "";
// Use smart summary if available
if (entry.metadata?.summary) {
return entry.metadata.summary;
}
// Fallback to truncated content
return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
}, [isExpanded, entry.metadata?.summary, entry.content]);
// Format content - detect and highlight JSON // Format content - detect and highlight JSON
const formattedContent = useMemo(() => { const formattedContent = useMemo(() => {
const content = entry.content; let content = entry.content;
// For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes
// since we already show the tool name in the header badge
if (isToolCall) {
// Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, "");
// Remove standalone "Input:" label (keep the JSON that follows)
content = content.replace(/^Input:\s*\n?/i, "");
content = content.trim();
}
// For summary entries, remove the <summary> and </summary> tags
if (entry.title === "Summary") {
content = content.replace(/^<summary>\s*/i, "");
content = content.replace(/\s*<\/summary>\s*$/i, "");
content = content.trim();
}
// Try to find and format JSON blocks // Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
@@ -103,14 +315,20 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
} }
return parts.length > 0 ? parts : [{ type: "text" as const, content }]; return parts.length > 0 ? parts : [{ type: "text" as const, content }];
}, [entry.content]); }, [entry.content, entry.title, isToolCall]);
// Get colors - use tool category colors for tool_call entries
const colorParts = toolCategoryColors.split(" ");
const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border-l-4 transition-all duration-200", "rounded-lg border-l-4 transition-all duration-200",
colors.bg, bgColor,
colors.border, borderColor,
"hover:brightness-110" "hover:brightness-110"
)} )}
data-testid={`log-entry-${entry.type}`} data-testid={`log-entry-${entry.type}`}
@@ -130,14 +348,14 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<span className="w-4 flex-shrink-0" /> <span className="w-4 flex-shrink-0" />
)} )}
<span className={cn("flex-shrink-0", colors.icon)}> <span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
{getLogIcon(entry.type)} {icon}
</span> </span>
<span <span
className={cn( className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0", "text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
colors.badge isToolCall ? toolCategoryColors : colors.badge
)} )}
data-testid="log-entry-badge" data-testid="log-entry-badge"
> >
@@ -145,9 +363,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
</span> </span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2"> <span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{!isExpanded && {collapsedPreview}
entry.content.slice(0, 80) +
(entry.content.length > 80 ? "..." : "")}
</span> </span>
</button> </button>
@@ -156,36 +372,140 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
className="px-4 pb-3 pt-1" className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`} data-testid={`log-entry-content-${entry.id}`}
> >
<div className="font-mono text-xs space-y-1"> {/* Render TodoWrite entries with special formatting */}
{formattedContent.map((part, index) => ( {parsedTodos ? (
<div key={index}> <TodoListRenderer todos={parsedTodos} />
{part.type === "json" ? ( ) : (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary"> <div className="font-mono text-xs space-y-1">
{part.content} {formattedContent.map((part, index) => (
</pre> <div key={index}>
) : ( {part.type === "json" ? (
<pre <pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
className={cn( {part.content}
"whitespace-pre-wrap break-words", </pre>
colors.text ) : (
)} <pre
> className={cn(
{part.content} "whitespace-pre-wrap break-words",
</pre> textColor
)} )}
</div> >
))} {part.content}
</div> </pre>
)}
</div>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
interface ToolCategoryStats {
read: number;
edit: number;
write: number;
bash: number;
search: number;
todo: number;
task: number;
other: number;
}
export function LogViewer({ output, className }: LogViewerProps) { export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
const entries = useMemo(() => parseLogOutput(output), [output]); // Parse entries and compute initial expanded state together
const { entries, initialExpandedIds } = useMemo(() => {
const parsedEntries = parseLogOutput(output);
const toExpand: string[] = [];
parsedEntries.forEach((entry) => {
// If entry should NOT collapse by default, mark it for expansion
if (!shouldCollapseByDefault(entry)) {
toExpand.push(entry.id);
}
});
return {
entries: parsedEntries,
initialExpandedIds: new Set(toExpand),
};
}, [output]);
// Merge initial expanded IDs with user-toggled ones
// Use a ref to track if we've applied initial state
const appliedInitialRef = useRef<Set<string>>(new Set());
// Apply initial expanded state for new entries
const effectiveExpandedIds = useMemo(() => {
const result = new Set(expandedIds);
initialExpandedIds.forEach((id) => {
if (!appliedInitialRef.current.has(id)) {
appliedInitialRef.current.add(id);
result.add(id);
}
});
return result;
}, [expandedIds, initialExpandedIds]);
// Calculate stats for tool categories
const stats = useMemo(() => {
const toolCalls = entries.filter((e) => e.type === "tool_call");
const byCategory: ToolCategoryStats = {
read: 0,
edit: 0,
write: 0,
bash: 0,
search: 0,
todo: 0,
task: 0,
other: 0,
};
toolCalls.forEach((tc) => {
const cat = tc.metadata?.toolCategory || "other";
byCategory[cat]++;
});
return {
total: toolCalls.length,
byCategory,
errors: entries.filter((e) => e.type === "error").length,
};
}, [entries]);
// Filter entries based on search and hidden types/categories
const filteredEntries = useMemo(() => {
return entries.filter((entry) => {
// Filter by hidden types
if (hiddenTypes.has(entry.type)) return false;
// Filter by hidden tool categories (for tool_call entries)
if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
}
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
entry.content.toLowerCase().includes(query) ||
entry.title.toLowerCase().includes(query) ||
entry.metadata?.toolName?.toLowerCase().includes(query) ||
entry.metadata?.summary?.toLowerCase().includes(query) ||
entry.metadata?.filePath?.toLowerCase().includes(query)
);
}
return true;
});
}, [entries, hiddenTypes, hiddenCategories, searchQuery]);
const toggleEntry = (id: string) => { const toggleEntry = (id: string) => {
setExpandedIds((prev) => { setExpandedIds((prev) => {
@@ -200,13 +520,45 @@ export function LogViewer({ output, className }: LogViewerProps) {
}; };
const expandAll = () => { const expandAll = () => {
setExpandedIds(new Set(entries.map((e) => e.id))); setExpandedIds(new Set(filteredEntries.map((e) => e.id)));
}; };
const collapseAll = () => { const collapseAll = () => {
setExpandedIds(new Set()); setExpandedIds(new Set());
}; };
const toggleTypeFilter = (type: LogEntryType) => {
setHiddenTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const toggleCategoryFilter = (category: ToolCategory) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const clearFilters = () => {
setSearchQuery("");
setHiddenTypes(new Set());
setHiddenCategories(new Set());
};
const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0;
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className="flex items-center justify-center p-8 text-muted-foreground"> <div className="flex items-center justify-center p-8 text-muted-foreground">
@@ -229,28 +581,123 @@ export function LogViewer({ output, className }: LogViewerProps) {
return acc; return acc;
}, {} as Record<string, number>); }, {} as Record<string, number>);
// Tool categories to display in stats bar
const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
{ key: "read", label: "Read" },
{ key: "edit", label: "Edit" },
{ key: "write", label: "Write" },
{ key: "bash", label: "Bash" },
{ key: "search", label: "Search" },
{ key: "todo", label: "Todo" },
{ key: "task", label: "Task" },
{ key: "other", label: "Other" },
];
return ( return (
<div className={cn("flex flex-col gap-2", className)}> <div className={cn("flex flex-col", className)}>
{/* Header with controls */} {/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
{/* Search bar */}
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
data-testid="log-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
colorClasses,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
</span>
)}
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header"> <div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => { {Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType); const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return ( return (
<span <button
key={type} key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn( className={cn(
"text-xs px-2 py-0.5 rounded-full", "text-xs px-2 py-0.5 rounded-full transition-all",
colors.badge colors.badge,
isHidden && "opacity-40 line-through"
)} )}
data-testid={`log-type-count-${type}`} title={isHidden ? `Show ${type}` : `Hide ${type}`}
data-testid={`log-type-filter-${type}`}
> >
{type}: {count} {type}: {count}
</span> </button>
); );
})} })}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button <button
onClick={expandAll} onClick={expandAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors" className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
@@ -267,17 +714,32 @@ export function LogViewer({ output, className }: LogViewerProps) {
</button> </button>
</div> </div>
</div> </div>
</div>
{/* Log entries */} {/* Log entries */}
<div className="space-y-2" data-testid="log-entries-container"> <div className="space-y-2 mt-2" data-testid="log-entries-container">
{entries.map((entry) => ( {filteredEntries.length === 0 ? (
<LogEntryItem <div className="text-center py-4 text-zinc-500 text-sm">
key={entry.id} No entries match your filters.
entry={entry} {hasActiveFilters && (
isExpanded={expandedIds.has(entry.id)} <button
onToggle={() => toggleEntry(entry.id)} onClick={clearFilters}
/> className="ml-2 text-primary hover:underline"
))} >
Clear filters
</button>
)}
</div>
) : (
filteredEntries.map((entry) => (
<LogEntryItem
key={entry.id}
entry={entry}
isExpanded={effectiveExpandedIds.has(entry.id)}
onToggle={() => toggleEntry(entry.id)}
/>
))
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore } from "@/store/app-store"; import { useAppStore, type AgentModel } from "@/store/app-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone"; import { ImageDropZone } from "@/components/ui/image-drop-zone";
@@ -18,6 +18,7 @@ import {
Paperclip, Paperclip,
X, X,
ImageIcon, ImageIcon,
ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent"; import { useElectronAgent } from "@/hooks/use-electron-agent";
@@ -29,6 +30,13 @@ import {
useKeyboardShortcutsConfig, useKeyboardShortcutsConfig,
KeyboardShortcut, KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts"; } from "@/hooks/use-keyboard-shortcuts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
export function AgentView() { export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = const { currentProject, setLastSelectedSession, getLastSelectedSession } =
@@ -41,6 +49,7 @@ export function AgentView() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true); const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
// Track if initial session has been loaded // Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false); const initialSessionLoadedRef = useRef(false);
@@ -66,6 +75,7 @@ export function AgentView() {
} = useElectronAgent({ } = useElectronAgent({
sessionId: currentSessionId || "", sessionId: currentSessionId || "",
workingDirectory: currentProject?.path, workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => { onToolUse: (toolName) => {
setCurrentTool(toolName); setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000); setTimeout(() => setCurrentTool(null), 2000);
@@ -501,6 +511,43 @@ export function AgentView() {
{/* Status indicators & actions */} {/* Status indicators & actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">
{model.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentTool && ( {currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border"> <div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" /> <Wrench className="w-3 h-3 text-primary" />

View File

@@ -10,6 +10,7 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store"; import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode"; import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -31,6 +32,12 @@ import {
FollowUpDialog, FollowUpDialog,
PlanApprovalDialog, PlanApprovalDialog,
} from "./board-view/dialogs"; } from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreePanel } from "./board-view/worktree-panel";
import { COLUMNS } from "./board-view/constants"; import { COLUMNS } from "./board-view/constants";
import { import {
useBoardFeatures, useBoardFeatures,
@@ -45,6 +52,11 @@ import {
useSuggestionsState, useSuggestionsState,
} from "./board-view/hooks"; } from "./board-view/hooks";
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
export function BoardView() { export function BoardView() {
const { const {
currentProject, currentProject,
@@ -60,6 +72,10 @@ export function BoardView() {
pendingPlanApproval, pendingPlanApproval,
setPendingPlanApproval, setPendingPlanApproval,
updateFeature, updateFeature,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
setWorktrees,
} = useAppStore(); } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const { const {
@@ -87,6 +103,24 @@ export function BoardView() {
// State for viewing plan in read-only mode // State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null); const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
} | null>(null);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Follow-up state hook // Follow-up state hook
const { const {
showFollowUpDialog, showFollowUpDialog,
@@ -194,32 +228,62 @@ export function BoardView() {
return [...new Set(allCategories)].sort(); return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]); }, [hookFeatures, persistedCategories]);
// Custom collision detection that prioritizes columns over cards // Branch suggestions for the branch autocomplete
const collisionDetectionStrategy = useCallback( // Shows all local branches as suggestions, but users can type any new branch name
(args: any) => { // When the feature is started, a worktree will be created if needed
// First, check if pointer is within a column const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
// If we found a column collision, use that // Fetch branches when project changes or worktrees are created/modified
if (columnCollisions.length > 0) { useEffect(() => {
return columnCollisions; const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
} }
// Otherwise, use rectangle intersection for cards try {
return rectIntersection(args); const api = getElectronAPI();
}, if (!api?.worktree?.listBranches) {
[] setBranchSuggestions([]);
); return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
// If we found a column collision, use that
if (columnCollisions.length > 0) {
return columnCollisions;
}
// Otherwise, use rectangle intersection for cards
return rectIntersection(args);
}, []);
// Use persistence hook // Use persistence hook
const { const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
persistFeatureCreate, useBoardPersistence({ currentProject });
persistFeatureUpdate,
persistFeatureDelete,
} = useBoardPersistence({ currentProject });
// Get in-progress features for keyboard shortcuts (needed before actions hook) // Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => { const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -229,6 +293,27 @@ export function BoardView() {
}); });
}, [hookFeatures, runningAutoTasks]); }, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path and branch) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
currentProject
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
// Extract all action handlers into a hook // Extract all action handlers into a hook
const { const {
handleAddFeature, handleAddFeature,
@@ -242,7 +327,6 @@ export function BoardView() {
handleOpenFollowUp, handleOpenFollowUp,
handleSendFollowUp, handleSendFollowUp,
handleCommitFeature, handleCommitFeature,
handleRevertFeature,
handleMergeFeature, handleMergeFeature,
handleCompleteFeature, handleCompleteFeature,
handleUnarchiveFeature, handleUnarchiveFeature,
@@ -273,6 +357,9 @@ export function BoardView() {
setShowFollowUpDialog, setShowFollowUpDialog,
inProgressFeaturesForShortcuts, inProgressFeaturesForShortcuts,
outputFeature, outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
currentWorktreeBranch,
}); });
// Use keyboard shortcuts hook (after actions hook) // Use keyboard shortcuts hook (after actions hook)
@@ -291,6 +378,8 @@ export function BoardView() {
runningAutoTasks, runningAutoTasks,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
}); });
// Use column features hook // Use column features hook
@@ -298,6 +387,9 @@ export function BoardView() {
features: hookFeatures, features: hookFeatures,
runningAutoTasks, runningAutoTasks,
searchQuery, searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath: currentProject?.path || null,
}); });
// Use background hook // Use background hook
@@ -473,6 +565,35 @@ export function BoardView() {
isMounted={isMounted} isMounted={isMounted}
/> />
{/* Worktree Panel */}
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({
id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */} {/* Search Bar Row */}
@@ -515,8 +636,6 @@ export function BoardView() {
onMoveBackToInProgress={handleMoveBackToInProgress} onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp} onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature} onCommit={handleCommitFeature}
onRevert={handleRevertFeature}
onMerge={handleMergeFeature}
onComplete={handleCompleteFeature} onComplete={handleCompleteFeature}
onImplement={handleStartImplementation} onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)} onViewPlan={(feature) => setViewPlanFeature(feature)}
@@ -564,7 +683,9 @@ export function BoardView() {
onOpenChange={setShowAddDialog} onOpenChange={setShowAddDialog}
onAdd={handleAddFeature} onAdd={handleAddFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests} defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
@@ -576,6 +697,7 @@ export function BoardView() {
onClose={() => setEditingFeature(null)} onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature} onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
@@ -656,6 +778,101 @@ export function BoardView() {
viewOnly={true} viewOnly={true}
/> />
)} )}
{/* Create Worktree Dialog */}
<CreateWorktreeDialog
open={showCreateWorktreeDialog}
onOpenChange={setShowCreateWorktreeDialog}
projectPath={currentProject.path}
onCreated={(newWorktree) => {
// Add the new worktree to the store immediately to avoid race condition
// when deriving currentWorktreeBranch for filtering
const currentWorktrees = getWorktrees(currentProject.path);
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
// Now set the current worktree with both path and branch
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
}}
/>
{/* Delete Worktree Dialog */}
<DeleteWorktreeDialog
open={showDeleteWorktreeDialog}
onOpenChange={setShowDeleteWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree
hookFeatures.forEach((feature) => {
const matchesByPath =
feature.worktreePath &&
pathsEqual(feature.worktreePath, deletedWorktree.path);
const matchesByBranch =
feature.branchName === deletedWorktree.branch;
if (matchesByPath || matchesByBranch) {
// Reset the feature's worktree assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
worktreePath: null as unknown as string | undefined,
});
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Commit Worktree Dialog */}
<CommitWorktreeDialog
open={showCommitWorktreeDialog}
onOpenChange={setShowCommitWorktreeDialog}
worktree={selectedWorktreeForAction}
onCommitted={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create PR Dialog */}
<CreatePRDialog
open={showCreatePRDialog}
onOpenChange={setShowCreatePRDialog}
worktree={selectedWorktreeForAction}
onCreated={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
{/* Create Branch Dialog */}
<CreateBranchDialog
open={showCreateBranchDialog}
onOpenChange={setShowCreateBranchDialog}
worktree={selectedWorktreeForAction}
onCreated={() => {
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/>
</div> </div>
); );
} }

View File

@@ -52,16 +52,16 @@ import {
MoreVertical, MoreVertical,
AlertCircle, AlertCircle,
GitBranch, GitBranch,
Undo2,
GitMerge,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Brain, Brain,
Wand2, Wand2,
Archive, Archive,
Lock,
} from "lucide-react"; } from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer"; import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
import { import {
parseAgentContext, parseAgentContext,
AgentTaskInfo, AgentTaskInfo,
@@ -103,8 +103,6 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void; onMoveBackToInProgress?: () => void;
onFollowUp?: () => void; onFollowUp?: () => void;
onCommit?: () => void; onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void; onImplement?: () => void;
onComplete?: () => void; onComplete?: () => void;
onViewPlan?: () => void; onViewPlan?: () => void;
@@ -132,8 +130,6 @@ export const KanbanCard = memo(function KanbanCard({
onMoveBackToInProgress, onMoveBackToInProgress,
onFollowUp, onFollowUp,
onCommit, onCommit,
onRevert,
onMerge,
onImplement, onImplement,
onComplete, onComplete,
onViewPlan, onViewPlan,
@@ -150,13 +146,18 @@ export const KanbanCard = memo(function KanbanCard({
}: 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 [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 [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore(); const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
const hasWorktree = !!feature.branchName; // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
const showSteps = const showSteps =
kanbanCardDetailLevel === "standard" || kanbanCardDetailLevel === "standard" ||
@@ -341,7 +342,7 @@ export const KanbanCard = memo(function KanbanCard({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className={cn( className={cn(
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10", "absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
"top-2 left-2 min-w-[36px]", "top-2 left-2 min-w-[36px]",
feature.priority === 1 && feature.priority === 1 &&
"bg-red-500/20 text-red-500 border-2 border-red-500/50", "bg-red-500/20 text-red-500 border-2 border-red-500/50",
@@ -352,7 +353,7 @@ export const KanbanCard = memo(function KanbanCard({
)} )}
data-testid={`priority-badge-${feature.id}`} data-testid={`priority-badge-${feature.id}`}
> >
P{feature.priority} {feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="text-xs"> <TooltipContent side="right" className="text-xs">
@@ -360,8 +361,8 @@ export const KanbanCard = memo(function KanbanCard({
{feature.priority === 1 {feature.priority === 1
? "High Priority" ? "High Priority"
: feature.priority === 2 : feature.priority === 2
? "Medium Priority" ? "Medium Priority"
: "Low Priority"} : "Low Priority"}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -377,23 +378,24 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
)} )}
{/* Skip Tests (Manual) indicator badge */} {/* Skip Tests (Manual) indicator badge - positioned at top right */}
{feature.skipTests && !feature.error && ( {feature.skipTests && !feature.error && feature.status === "backlog" && (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10", "absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
feature.priority ? "top-11 left-2" : "top-2 left-2", "min-w-[36px]",
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]" "top-2 right-2",
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
)} )}
data-testid={`skip-tests-badge-${feature.id}`} data-testid={`skip-tests-badge-${feature.id}`}
> >
<Hand className="w-3 h-3" /> <Hand className="w-4 h-4" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="text-xs"> <TooltipContent side="left" className="text-xs">
<p>Manual verification required</p> <p>Manual verification required</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -407,13 +409,14 @@ export const KanbanCard = memo(function KanbanCard({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10", "absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
"min-w-[36px]",
feature.priority ? "top-11 left-2" : "top-2 left-2", feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]" "bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)} )}
data-testid={`error-badge-${feature.id}`} data-testid={`error-badge-${feature.id}`}
> >
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3.5 h-3.5" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="text-xs max-w-[250px]"> <TooltipContent side="right" className="text-xs max-w-[250px]">
@@ -423,16 +426,42 @@ export const KanbanCard = memo(function KanbanCard({
</TooltipProvider> </TooltipProvider>
)} )}
{/* Blocked by dependencies badge - positioned at top right */}
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
"min-w-[36px]",
"top-2 right-2",
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
<p className="text-muted-foreground">
{blockingDependencies.map(depId => {
const dep = features.find(f => f.id === depId);
return dep?.description || depId;
}).join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished indicator badge */} {/* Just Finished indicator badge */}
{isJustFinished && ( {isJustFinished && (
<div <div
className={cn( className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10", "absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.priority feature.priority ? "top-11 left-2" : "top-2 left-2",
? "top-11 left-2"
: feature.skipTests
? "top-8 left-2"
: "top-2 left-2",
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]", "bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse" "animate-pulse"
)} )}
@@ -443,45 +472,13 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
)} )}
{/* Branch badge */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
feature.priority
? "top-11 left-2"
: feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">
{feature.branchName}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<CardHeader <CardHeader
className={cn( className={cn(
"p-3 pb-2 block", "p-3 pb-2 block",
feature.priority && "pt-12", feature.priority && "pt-12",
!feature.priority && !feature.priority &&
(feature.skipTests || feature.error || isJustFinished) && (feature.skipTests || feature.error || isJustFinished) &&
"pt-10", "pt-10"
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)} )}
> >
{isCurrentAutoTask && ( {isCurrentAutoTask && (
@@ -499,7 +496,7 @@ export const KanbanCard = memo(function KanbanCard({
</div> </div>
)} )}
{!isCurrentAutoTask && feature.status === "backlog" && ( {!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2"> <div className="absolute bottom-1 right-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -518,43 +515,110 @@ export const KanbanCard = memo(function KanbanCard({
{!isCurrentAutoTask && {!isCurrentAutoTask &&
(feature.status === "waiting_approval" || (feature.status === "waiting_approval" ||
feature.status === "verified") && ( feature.status === "verified") && (
<div className="absolute top-2 right-2 flex items-center gap-1"> <>
<Button <div className="absolute top-2 right-2 flex items-center gap-1">
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground" className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onViewOutput(); onEdit();
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${ data-testid={`edit-${
feature.status === "waiting_approval" feature.status === "waiting_approval" ? "waiting" : "verified"
? "waiting"
: "verified"
}-${feature.id}`} }-${feature.id}`}
title="Logs" title="Edit"
> >
<FileText className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
)} {onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
</div>
<div className="absolute bottom-1 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
className="text-xs"
>
<FileText className="w-3 h-3 mr-2" />
View Logs
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="absolute bottom-1 right-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -564,69 +628,13 @@ export const KanbanCard = memo(function KanbanCard({
handleDeleteClick(e); handleDeleteClick(e);
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${ data-testid={`delete-feature-${feature.id}`}
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Delete" title="Delete"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
</div> </div>
)} </>
{!isCurrentAutoTask && feature.status === "in_progress" && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
className="text-xs"
>
<FileText className="w-3 h-3 mr-2" />
View Logs
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-xs text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e as unknown as React.MouseEvent);
}}
data-testid={`delete-feature-${feature.id}`}
>
<Trash2 className="w-3 h-3 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)} )}
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{isDraggable && ( {isDraggable && (
@@ -679,6 +687,16 @@ export const KanbanCard = memo(function KanbanCard({
</CardHeader> </CardHeader>
<CardContent className="p-3 pt-0"> <CardContent className="p-3 pt-0">
{/* Target Branch Display */}
{useWorktrees && feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}>
{feature.branchName}
</span>
</div>
)}
{/* Steps Preview */} {/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && ( {showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
@@ -884,9 +902,9 @@ export const KanbanCard = memo(function KanbanCard({
)} )}
{onViewOutput && ( {onViewOutput && (
<Button <Button
variant="default" variant="secondary"
size="sm" size="sm"
className="flex-1 min-w-0 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90" className="flex-1 h-7 text-[11px]"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onViewOutput(); onViewOutput();
@@ -898,7 +916,7 @@ export const KanbanCard = memo(function KanbanCard({
<span className="truncate">Logs</span> <span className="truncate">Logs</span>
{shortcutKey && ( {shortcutKey && (
<span <span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20 shrink-0" className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`} data-testid={`shortcut-key-${feature.id}`}
> >
{shortcutKey} {shortcutKey}
@@ -1007,7 +1025,7 @@ export const KanbanCard = memo(function KanbanCard({
)} )}
{!isCurrentAutoTask && feature.status === "verified" && ( {!isCurrentAutoTask && feature.status === "verified" && (
<> <>
{/* Logs button - styled like Refine */} {/* Logs button */}
{onViewOutput && ( {onViewOutput && (
<Button <Button
variant="secondary" variant="secondary"
@@ -1045,30 +1063,6 @@ export const KanbanCard = memo(function KanbanCard({
)} )}
{!isCurrentAutoTask && feature.status === "waiting_approval" && ( {!isCurrentAutoTask && feature.status === "waiting_approval" && (
<> <>
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Refine prompt button */} {/* Refine prompt button */}
{onFollowUp && ( {onFollowUp && (
<Button <Button
@@ -1086,24 +1080,7 @@ export const KanbanCard = memo(function KanbanCard({
<span className="truncate">Refine</span> <span className="truncate">Refine</span>
</Button> </Button>
)} )}
{hasWorktree && onMerge && ( {onCommit && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{!hasWorktree && onCommit && (
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@@ -1228,54 +1205,6 @@ export const KanbanCard = memo(function KanbanCard({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-[var(--status-error)] font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card> </Card>
); );

View File

@@ -14,12 +14,20 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button"; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { import {
DescriptionImageDropZone, DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath, FeatureImagePath as DescriptionImagePath,
ImagePreviewMap, ImagePreviewMap,
} from "@/components/ui/description-image-dropzone"; } from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown } from "lucide-react"; import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils"; import { modelSupportsThinking } from "@/lib/utils";
@@ -58,12 +66,15 @@ interface AddFeatureDialogProps {
skipTests: boolean; skipTests: boolean;
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
branchName: string;
priority: number; priority: number;
planningMode: PlanningMode; planningMode: PlanningMode;
requirePlanApproval: boolean; requirePlanApproval: boolean;
}) => void; }) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[];
defaultSkipTests: boolean; defaultSkipTests: boolean;
defaultBranch?: string;
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
@@ -74,7 +85,9 @@ export function AddFeatureDialog({
onOpenChange, onOpenChange,
onAdd, onAdd,
categorySuggestions, categorySuggestions,
branchSuggestions,
defaultSkipTests, defaultSkipTests,
defaultBranch = "main",
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
aiProfiles, aiProfiles,
@@ -88,6 +101,7 @@ export function AddFeatureDialog({
skipTests: false, skipTests: false,
model: "opus" as AgentModel, model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel, thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
priority: 2 as number, // Default to medium priority priority: 2 as number, // Default to medium priority
}); });
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -95,12 +109,14 @@ export function AddFeatureDialog({
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false); const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve'); const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip'); const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false); const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model and default planning mode from store // Get enhancement model, planning mode defaults, and worktrees setting from store
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval } = useAppStore(); const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Sync defaults when dialog opens // Sync defaults when dialog opens
useEffect(() => { useEffect(() => {
@@ -108,11 +124,12 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({ setNewFeature((prev) => ({
...prev, ...prev,
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
branchName: defaultBranch,
})); }));
setPlanningMode(defaultPlanningMode); setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
} }
}, [open, defaultSkipTests, defaultPlanningMode, defaultRequirePlanApproval]); }, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
const handleAdd = () => { const handleAdd = () => {
if (!newFeature.description.trim()) { if (!newFeature.description.trim()) {
@@ -135,6 +152,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests, skipTests: newFeature.skipTests,
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
priority: newFeature.priority, priority: newFeature.priority,
planningMode, planningMode,
requirePlanApproval, requirePlanApproval,
@@ -151,6 +169,7 @@ export function AddFeatureDialog({
model: "opus", model: "opus",
priority: 2, priority: 2,
thinkingLevel: "none", thinkingLevel: "none",
branchName: defaultBranch,
}); });
setPlanningMode(defaultPlanningMode); setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
@@ -183,7 +202,7 @@ export function AddFeatureDialog({
if (result?.success && result.enhancedText) { if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText; const enhancedText = result.enhancedText;
setNewFeature(prev => ({ ...prev, description: enhancedText })); setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!"); toast.success("Description enhanced!");
} else { } else {
toast.error(result?.error || "Failed to enhance description"); toast.error(result?.error || "Failed to enhance description");
@@ -206,7 +225,10 @@ export function AddFeatureDialog({
}); });
}; };
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
setNewFeature({ setNewFeature({
...newFeature, ...newFeature,
model, model,
@@ -260,7 +282,10 @@ export function AddFeatureDialog({
</TabsList> </TabsList>
{/* Prompt Tab */} {/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<DescriptionImageDropZone <DescriptionImageDropZone
@@ -285,25 +310,38 @@ export function AddFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default"> <div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-[200px] justify-between"> <Button
{enhancementMode === 'improve' && 'Improve Clarity'} variant="outline"
{enhancementMode === 'technical' && 'Add Technical Details'} size="sm"
{enhancementMode === 'simplify' && 'Simplify'} className="w-[200px] justify-between"
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'} >
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" /> <ChevronDown className="w-4 h-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}> <DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity Improve Clarity
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}> <DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details Add Technical Details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}> <DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify Simplify
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}> <DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria Add Acceptance Criteria
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -333,6 +371,24 @@ export function AddFeatureDialog({
data-testid="feature-category-input" data-testid="feature-category-input"
/> />
</div> </div>
{useWorktrees && (
<div className="space-y-2">
<Label htmlFor="branch">Target Branch</Label>
<BranchAutocomplete
value={newFeature.branchName}
onChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
branches={branchSuggestions}
placeholder="Select or create branch..."
data-testid="feature-branch-input"
/>
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if
needed.
</p>
</div>
)}
{/* Priority Selector */} {/* Priority Selector */}
<PrioritySelector <PrioritySelector
@@ -345,7 +401,10 @@ export function AddFeatureDialog({
</TabsContent> </TabsContent>
{/* Model Tab */} {/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */} {/* Show Advanced Options Toggle */}
{showProfilesOnly && ( {showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border"> <div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
@@ -429,9 +488,7 @@ export function AddFeatureDialog({
setNewFeature({ ...newFeature, skipTests }) setNewFeature({ ...newFeature, skipTests })
} }
steps={newFeature.steps} steps={newFeature.steps}
onStepsChange={(steps) => onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
setNewFeature({ ...newFeature, steps })
}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -100,24 +100,6 @@ export function AgentOutputModal({
loadOutput(); loadOutput();
}, [open, featureId]); }, [open, featureId]);
// Save output to file
const saveOutput = async (newContent: string) => {
if (!projectPathRef.current) return;
const api = getElectronAPI();
if (!api) return;
try {
// Use features API - agent output is stored in features/{id}/agent-output.md
// We need to write it directly since there's no updateAgentOutput method
// The context-manager handles this on the backend, but for frontend edits we write directly
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
await api.writeFile(outputPath, newContent);
} catch (error) {
console.error("Failed to save output:", error);
}
};
// Listen to auto mode events and update output // Listen to auto mode events and update output
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -143,7 +125,7 @@ export function AgentOutputModal({
? JSON.stringify(event.input, null, 2) ? JSON.stringify(event.input, null, 2)
: ""; : "";
newContent = `\n🔧 Tool: ${toolName}\n${ newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : "" toolInput ? `Input: ${toolInput}\n` : ""
}`; }`;
break; break;
case "auto_mode_phase": case "auto_mode_phase":
@@ -261,11 +243,8 @@ export function AgentOutputModal({
} }
if (newContent) { if (newContent) {
setOutput((prev) => { // Only update local state - server is the single source of truth for file writes
const updated = prev + newContent; setOutput((prev) => prev + newContent);
saveOutput(updated);
return updated;
});
} }
}); });

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { GitCommit, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCommitted: () => void;
}
export function CommitWorktreeDialog({
open,
onOpenChange,
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.commit(worktree.path, message);
if (result.success && result.result) {
if (result.result.committed) {
toast.success("Changes committed", {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage("");
} else {
toast.info("No changes to commit", {
description: result.result.message,
});
}
} else {
setError(result.error || "Failed to commit changes");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to commit");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) {
handleCommit();
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit Changes
</DialogTitle>
<DialogDescription>
Commit changes in the{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>{" "}
worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? "s" : ""} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd+Enter</kbd> to commit
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={isLoading || !message.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing...
</>
) : (
<>
<GitCommit className="w-4 h-4 mr-2" />
Commit
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { GitBranchPlus, Loader2 } from "lucide-react";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreateBranchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
}
export function CreateBranchDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setBranchName("");
setError(null);
}
}, [open]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
// Basic validation
const invalidChars = /[\s~^:?*[\]\\]/;
if (invalidChars.test(branchName)) {
setError("Branch name contains invalid characters");
return;
}
setIsCreating(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.checkoutBranch) {
toast.error("Branch API not available");
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
if (result.success && result.result) {
toast.success(result.result.message);
onCreated();
onOpenChange(false);
} else {
setError(result.error || "Failed to create branch");
}
} catch (err) {
console.error("Create branch failed:", err);
setError("Failed to create branch");
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from <span className="font-mono text-foreground">{worktree?.branch || "current branch"}</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && branchName.trim() && !isCreating) {
handleCreate();
}
}}
disabled={isCreating}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!branchName.trim() || isCreating}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Branch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,375 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { GitPullRequest, Loader2, ExternalLink } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CreatePRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
}
export function CreatePRDialog({
open,
onOpenChange,
worktree,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [baseBranch, setBaseBranch] = useState("main");
const [commitMessage, setCommitMessage] = useState("");
const [isDraft, setIsDraft] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
// These are set by the API response and should persist until dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
} else {
// Reset everything when dialog closes
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}
}, [open, worktree?.path]);
const handleCreate = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.createPR) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.createPR(worktree.path, {
commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
});
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
onCreated();
} else {
// Branch was pushed successfully
const prError = result.result.prError;
const hasBrowserUrl = !!result.result.browserUrl;
// Check if we should show browser fallback
if (!result.result.prCreated && hasBrowserUrl) {
// If gh CLI is not available, show browser fallback UI
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return; // Don't close dialog, show browser fallback UI
}
// gh CLI is available but failed - show error with browser option
if (prError) {
// Parse common gh CLI errors for better messages
let errorMessage = prError;
if (prError.includes("No commits between")) {
errorMessage = "No new commits to create PR. Make sure your branch has changes compared to the base branch.";
} else if (prError.includes("already exists")) {
errorMessage = "A pull request already exists for this branch.";
} else if (prError.includes("not logged in") || prError.includes("auth")) {
errorMessage = "GitHub CLI not authenticated. Run 'gh auth login' in terminal.";
}
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
});
// Don't call onCreated() here - we want to keep the dialog open to show the browser URL
setIsLoading(false);
return;
}
}
// Show success toast for push
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
: `Branch ${result.result.branch} pushed`,
});
// No browser URL available, just close
if (!result.result.prCreated) {
if (!hasBrowserUrl) {
toast.info("PR not created", {
description: "Could not determine repository URL. GitHub CLI (gh) may not be installed or authenticated.",
duration: 8000,
});
}
}
onCreated();
onOpenChange(false);
}
} else {
setError(result.error || "Failed to create pull request");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create PR");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after dialog closes
setTimeout(() => {
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}, 200);
};
if (!worktree) return null;
const shouldShowBrowserFallback = showBrowserFallback && browserUrl;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitPullRequest className="w-5 h-5" />
Create Pull Request
</DialogTitle>
<DialogDescription>
Push changes and create a pull request from{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</DialogDescription>
</DialogHeader>
{prUrl ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-500/10">
<GitPullRequest className="w-8 h-8 text-green-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Pull Request Created!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your PR is ready for review
</p>
</div>
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
</div>
) : shouldShowBrowserFallback ? (
<div className="py-6 text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/10">
<GitPullRequest className="w-8 h-8 text-blue-500" />
</div>
<div>
<h3 className="text-lg font-semibold">Branch Pushed!</h3>
<p className="text-sm text-muted-foreground mt-1">
Your changes have been pushed to GitHub.
<br />
Click below to create a pull request in your browser.
</p>
</div>
<div className="space-y-3">
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, "_blank");
}
}}
className="gap-2 w-full"
size="lg"
>
<ExternalLink className="w-4 h-4" />
Create PR in Browser
</Button>
<div className="p-2 bg-muted rounded text-xs break-all font-mono">
{browserUrl}
</div>
<p className="text-xs text-muted-foreground">
Tip: Install the GitHub CLI (<code className="bg-muted px-1 rounded">gh</code>) to create PRs directly from the app
</p>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</DialogFooter>
</div>
</div>
) : (
<>
<div className="grid gap-4 py-4">
{worktree.hasChanges && (
<div className="grid gap-2">
<Label htmlFor="commit-message">
Commit Message{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="commit-message"
placeholder="Leave empty to auto-generate"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{worktree.changedFilesCount} uncommitted file(s) will be
committed
</p>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<Input
id="pr-title"
placeholder={worktree.branch}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pr-body">Description</Label>
<Textarea
id="pr-body"
placeholder="Describe the changes in this PR..."
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[80px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="flex items-end">
<div className="flex items-center space-x-2">
<Checkbox
id="draft"
checked={isDraft}
onCheckedChange={(checked) => setIsDraft(checked === true)}
/>
<Label htmlFor="draft" className="cursor-pointer">
Create as draft
</Label>
</div>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitPullRequest className="w-4 h-4 mr-2" />
Create PR
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GitBranch, Loader2 } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface CreatedWorktreeInfo {
path: string;
branch: string;
}
interface CreateWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
onCreated: (worktree: CreatedWorktreeInfo) => void;
}
export function CreateWorktreeDialog({
open,
onOpenChange,
projectPath,
onCreated,
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError("Branch name is required");
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError(
"Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes."
);
return;
}
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError("Worktree API not available");
return;
}
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
toast.success(
`Worktree created for branch "${result.worktree.branch}"`,
{
description: result.worktree.isNew
? "New branch created"
: "Using existing branch",
}
);
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
onOpenChange(false);
setBranchName("");
} else {
setError(result.error || "Failed to create worktree");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create worktree");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading && branchName.trim()) {
handleCreate();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Create New Worktree
</DialogTitle>
<DialogDescription>
Create a new git worktree with its own branch. This allows you to
work on multiple features in parallel.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
placeholder="feature/my-new-feature"
value={branchName}
onChange={(e) => {
setBranchName(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm"
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside pl-2 space-y-0.5">
<li>
<code className="bg-muted px-1 rounded">feature/user-auth</code>
</li>
<li>
<code className="bg-muted px-1 rounded">fix/login-bug</code>
</li>
<li>
<code className="bg-muted px-1 rounded">hotfix/security-patch</code>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isLoading || !branchName.trim()}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<GitBranch className="w-4 h-4 mr-2" />
Create Worktree
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, Trash2, AlertTriangle } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface DeleteWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
}
export function DeleteWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onDeleted,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleDelete = async () => {
if (!worktree) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.delete) {
toast.error("Worktree API not available");
return;
}
const result = await api.worktree.delete(
projectPath,
worktree.path,
deleteBranch
);
if (result.success) {
toast.success(`Worktree deleted`, {
description: deleteBranch
? `Branch "${worktree.branch}" was also deleted`
: `Branch "${worktree.branch}" was kept`,
});
onDeleted(worktree, deleteBranch);
onOpenChange(false);
setDeleteBranch(false);
} else {
toast.error("Failed to delete worktree", {
description: result.error,
});
}
} catch (err) {
toast.error("Failed to delete worktree", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
Delete Worktree
</DialogTitle>
<DialogDescription className="space-y-3">
<span>
Are you sure you want to delete the worktree for branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
?
</span>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted
change(s). These will be lost if you proceed.
</span>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="delete-branch"
checked={deleteBranch}
onCheckedChange={(checked) => setDeleteBranch(checked === true)}
/>
<Label htmlFor="delete-branch" className="text-sm cursor-pointer">
Also delete the branch{" "}
<code className="font-mono bg-muted px-1 rounded">
{worktree.branch}
</code>
</Label>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -14,12 +14,21 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button"; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { import {
DescriptionImageDropZone, DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath, FeatureImagePath as DescriptionImagePath,
ImagePreviewMap, ImagePreviewMap,
} from "@/components/ui/description-image-dropzone"; } from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, SlidersHorizontal, Sparkles, ChevronDown, GitBranch } from "lucide-react"; import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils"; import { modelSupportsThinking } from "@/lib/utils";
@@ -60,12 +69,14 @@ interface EditFeatureDialogProps {
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[]; imagePaths: DescriptionImagePath[];
branchName: string;
priority: number; priority: number;
planningMode: PlanningMode; planningMode: PlanningMode;
requirePlanApproval: boolean; requirePlanApproval: boolean;
} }
) => void; ) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[];
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
@@ -77,6 +88,7 @@ export function EditFeatureDialog({
onClose, onClose,
onUpdate, onUpdate,
categorySuggestions, categorySuggestions,
branchSuggestions,
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
aiProfiles, aiProfiles,
@@ -87,13 +99,15 @@ export function EditFeatureDialog({
useState<ImagePreviewMap>(() => new Map()); useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve'); const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false); const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip'); const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false); const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
// Get enhancement model from store // Get enhancement model and worktrees setting from store
const { enhancementModel } = useAppStore(); const { enhancementModel, useWorktrees } = useAppStore();
useEffect(() => { useEffect(() => {
setEditingFeature(feature); setEditingFeature(feature);
@@ -110,8 +124,10 @@ export function EditFeatureDialog({
if (!editingFeature) return; if (!editingFeature) return;
const selectedModel = (editingFeature.model ?? "opus") as AgentModel; const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel) const normalizedThinking: ThinkingLevel = modelSupportsThinking(
? (editingFeature.thinkingLevel ?? "none") selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none"; : "none";
const updates = { const updates = {
@@ -122,6 +138,7 @@ export function EditFeatureDialog({
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [], imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
priority: editingFeature.priority ?? 2, priority: editingFeature.priority ?? 2,
planningMode, planningMode,
requirePlanApproval, requirePlanApproval,
@@ -150,7 +167,10 @@ export function EditFeatureDialog({
}); });
}; };
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
if (!editingFeature) return; if (!editingFeature) return;
setEditingFeature({ setEditingFeature({
...editingFeature, ...editingFeature,
@@ -173,7 +193,9 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) { if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText; const enhancedText = result.enhancedText;
setEditingFeature(prev => prev ? { ...prev, description: enhancedText } : prev); setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!"); toast.success("Description enhanced!");
} else { } else {
toast.error(result?.error || "Failed to enhance description"); toast.error(result?.error || "Failed to enhance description");
@@ -234,7 +256,10 @@ export function EditFeatureDialog({
</TabsList> </TabsList>
{/* Prompt Tab */} {/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="edit-description">Description</Label> <Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone <DescriptionImageDropZone
@@ -261,25 +286,38 @@ export function EditFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default"> <div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-[180px] justify-between"> <Button
{enhancementMode === 'improve' && 'Improve Clarity'} variant="outline"
{enhancementMode === 'technical' && 'Add Technical Details'} size="sm"
{enhancementMode === 'simplify' && 'Simplify'} className="w-[180px] justify-between"
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'} >
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" /> <ChevronDown className="w-4 h-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}> <DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity Improve Clarity
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}> <DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details Add Technical Details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}> <DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify Simplify
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}> <DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria Add Acceptance Criteria
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -312,6 +350,35 @@ export function EditFeatureDialog({
data-testid="edit-feature-category" data-testid="edit-feature-category"
/> />
</div> </div>
{useWorktrees && (
<div className="space-y-2">
<Label htmlFor="edit-branch">Target Branch</Label>
<BranchAutocomplete
value={editingFeature.branchName ?? "main"}
onChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branches={branchSuggestions}
placeholder="Select or create branch..."
data-testid="edit-feature-branch"
disabled={editingFeature.status !== "backlog"}
/>
{editingFeature.status !== "backlog" && (
<p className="text-xs text-muted-foreground">
Branch cannot be changed after work has started.
</p>
)}
{editingFeature.status === "backlog" && (
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created
if needed.
</p>
)}
</div>
)}
{/* Priority Selector */} {/* Priority Selector */}
<PrioritySelector <PrioritySelector
@@ -327,7 +394,10 @@ export function EditFeatureDialog({
</TabsContent> </TabsContent>
{/* Model Tab */} {/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */} {/* Show Advanced Options Toggle */}
{showProfilesOnly && ( {showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border"> <div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">

View File

@@ -1,10 +1,18 @@
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { Feature, FeatureImage, AgentModel, ThinkingLevel, PlanningMode, useAppStore } from "@/store/app-store"; import {
Feature,
FeatureImage,
AgentModel,
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone"; import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAutoMode } from "@/hooks/use-auto-mode"; import { useAutoMode } from "@/hooks/use-auto-mode";
import { truncateDescription } from "@/lib/utils"; import { truncateDescription } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
interface UseBoardActionsProps { interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null; currentProject: { path: string; id: string } | null;
@@ -12,7 +20,10 @@ interface UseBoardActionsProps {
runningAutoTasks: string[]; runningAutoTasks: string[];
loadFeatures: () => Promise<void>; loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>; persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>; persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>; persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>; saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void; setEditingFeature: (feature: Feature | null) => void;
@@ -28,6 +39,9 @@ interface UseBoardActionsProps {
setShowFollowUpDialog: (show: boolean) => void; setShowFollowUpDialog: (show: boolean) => void;
inProgressFeaturesForShortcuts: Feature[]; inProgressFeaturesForShortcuts: Feature[];
outputFeature: Feature | null; outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
} }
export function useBoardActions({ export function useBoardActions({
@@ -52,12 +66,81 @@ export function useBoardActions({
setShowFollowUpDialog, setShowFollowUpDialog,
inProgressFeaturesForShortcuts, inProgressFeaturesForShortcuts,
outputFeature, outputFeature,
projectPath,
onWorktreeCreated,
currentWorktreeBranch,
}: UseBoardActionsProps) { }: UseBoardActionsProps) {
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore(); const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
enableDependencyBlocking,
} = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
/**
* Get or create the worktree path for a feature based on its branchName.
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[BoardActions] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error(
"[BoardActions] Failed to create worktree:",
result.error
);
toast.error("Failed to create worktree", {
description:
result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[BoardActions] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleAddFeature = useCallback( const handleAddFeature = useCallback(
(featureData: { async (featureData: {
category: string; category: string;
description: string; description: string;
steps: string[]; steps: string[];
@@ -66,23 +149,43 @@ export function useBoardActions({
skipTests: boolean; skipTests: boolean;
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
branchName: string;
priority: number; priority: number;
planningMode: PlanningMode; planningMode: PlanningMode;
requirePlanApproval: boolean; requirePlanApproval: boolean;
}) => { }) => {
let worktreePath: string | undefined;
// If worktrees are enabled and a non-main branch is selected, create the worktree
if (useWorktrees && featureData.branchName) {
const branchName = featureData.branchName;
if (branchName !== "main" && branchName !== "master") {
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
const tempFeature = { branchName } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
const newFeatureData = { const newFeatureData = {
...featureData, ...featureData,
status: "backlog" as const, status: "backlog" as const,
worktreePath,
}; };
const createdFeature = addFeature(newFeatureData); const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature); // Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category); saveCategory(featureData.category);
}, },
[addFeature, persistFeatureCreate, saveCategory] [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
); );
const handleUpdateFeature = useCallback( const handleUpdateFeature = useCallback(
( async (
featureId: string, featureId: string,
updates: { updates: {
category: string; category: string;
@@ -92,19 +195,59 @@ export function useBoardActions({
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[]; imagePaths: DescriptionImagePath[];
branchName: string;
priority: number; priority: number;
planningMode?: PlanningMode; planningMode?: PlanningMode;
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
} }
) => { ) => {
updateFeature(featureId, updates); // Get the current feature to check if branch is changing
persistFeatureUpdate(featureId, updates); const currentFeature = features.find((f) => f.id === featureId);
const currentBranch = currentFeature?.branchName || "main";
const newBranch = updates.branchName || "main";
const branchIsChanging = currentBranch !== newBranch;
let worktreePath: string | undefined;
let shouldClearWorktreePath = false;
// If worktrees are enabled and branch is changing to a non-main branch, create worktree
if (useWorktrees && branchIsChanging) {
if (newBranch === "main" || newBranch === "master") {
// Changing to main - clear the worktreePath
shouldClearWorktreePath = true;
} else {
// Changing to a feature branch - create worktree if needed
const tempFeature = { branchName: newBranch } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
// Build final updates with worktreePath if it was changed
let finalUpdates: typeof updates & { worktreePath?: string };
if (branchIsChanging && useWorktrees) {
if (shouldClearWorktreePath) {
// Use null to clear the value in persistence (cast to work around type system)
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
} else {
finalUpdates = { ...updates, worktreePath };
}
} else {
finalUpdates = updates;
}
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
if (updates.category) { if (updates.category) {
saveCategory(updates.category); saveCategory(updates.category);
} }
setEditingFeature(null); setEditingFeature(null);
}, },
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(
@@ -118,7 +261,9 @@ export function useBoardActions({
try { try {
await autoMode.stopFeature(featureId); await autoMode.stopFeature(featureId);
toast.success("Agent stopped", { toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`, description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
}); });
} catch (error) { } catch (error) {
console.error("[Board] Error stopping feature before delete:", error); console.error("[Board] Error stopping feature before delete:", error);
@@ -136,11 +281,17 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path); await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`); console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) { } catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error); console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
} }
} }
} catch (error) { } catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error); console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
} }
} }
@@ -161,14 +312,22 @@ export function useBoardActions({
return; return;
} }
// Use the feature's assigned worktreePath (set when moving to in_progress)
// This ensures work happens in the correct worktree based on the feature's branchName
const featureWorktreePath = feature.worktreePath;
const result = await api.autoMode.runFeature( const result = await api.autoMode.runFeature(
currentProject.path, currentProject.path,
feature.id, feature.id,
useWorktrees useWorktrees,
featureWorktreePath || undefined
); );
if (result.success) { if (result.success) {
console.log("[Board] Feature run started successfully"); console.log(
"[Board] Feature run started successfully in worktree:",
featureWorktreePath || "main"
);
} else { } else {
console.error("[Board] Failed to run feature:", result.error); console.error("[Board] Failed to run feature:", result.error);
await loadFeatures(); await loadFeatures();
@@ -192,17 +351,33 @@ export function useBoardActions({
return false; return false;
} }
// Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) {
const blockingDeps = getBlockingDependencies(feature, features);
if (blockingDeps.length > 0) {
const depDescriptions = blockingDeps.map(depId => {
const dep = features.find(f => f.id === depId);
return dep ? truncateDescription(dep.description, 40) : depId;
}).join(", ");
toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`,
});
}
}
const updates = { const updates = {
status: "in_progress" as const, status: "in_progress" as const,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
}; };
updateFeature(feature.id, updates); updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates); // Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent..."); console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature); await handleRunFeature(feature);
return true; return true;
}, },
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature] [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
); );
const handleVerifyFeature = useCallback( const handleVerifyFeature = useCallback(
@@ -216,7 +391,10 @@ export function useBoardActions({
return; return;
} }
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id); const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
if (result.success) { if (result.success) {
console.log("[Board] Feature verification started successfully"); console.log("[Board] Feature verification started successfully");
@@ -243,7 +421,11 @@ export function useBoardActions({
return; return;
} }
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id); const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
if (result.success) { if (result.success) {
console.log("[Board] Feature resume started successfully"); console.log("[Board] Feature resume started successfully");
@@ -256,7 +438,7 @@ export function useBoardActions({
await loadFeatures(); await loadFeatures();
} }
}, },
[currentProject, loadFeatures] [currentProject, loadFeatures, useWorktrees]
); );
const handleManualVerify = useCallback( const handleManualVerify = useCallback(
@@ -267,7 +449,9 @@ export function useBoardActions({
justFinishedAt: undefined, justFinishedAt: undefined,
}); });
toast.success("Feature verified", { toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(feature.description)}`, description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
}); });
}, },
[moveFeature, persistFeatureUpdate] [moveFeature, persistFeatureUpdate]
@@ -282,7 +466,9 @@ export function useBoardActions({
updateFeature(feature.id, updates); updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates); persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", { toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`, description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
}); });
}, },
[updateFeature, persistFeatureUpdate] [updateFeature, persistFeatureUpdate]
@@ -295,7 +481,12 @@ export function useBoardActions({
setFollowUpImagePaths([]); setFollowUpImagePaths([]);
setShowFollowUpDialog(true); setShowFollowUpDialog(true);
}, },
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog] [
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
); );
const handleSendFollowUp = useCallback(async () => { const handleSendFollowUp = useCallback(async () => {
@@ -328,17 +519,28 @@ export function useBoardActions({
setFollowUpImagePaths([]); setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map()); setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", { toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(featureDescription)}`, description: `Continuing work on: ${truncateDescription(
}); featureDescription
)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path); const imagePaths = followUpImagePaths.map((img) => img.path);
// Use the feature's worktreePath to ensure work happens in the correct branch
const featureWorktreePath = followUpFeature.worktreePath;
api.autoMode api.autoMode
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths) .followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths,
featureWorktreePath
)
.catch((error) => { .catch((error) => {
console.error("[Board] Error sending follow-up:", error); console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", { toast.error("Failed to send follow-up", {
description: error instanceof Error ? error.message : "An error occurred", description:
error instanceof Error ? error.message : "An error occurred",
}); });
loadFeatures(); loadFeatures();
}); });
@@ -366,19 +568,29 @@ export function useBoardActions({
if (!api?.autoMode?.commitFeature) { if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available"); console.error("Commit feature API not available");
toast.error("Commit not available", { toast.error("Commit not available", {
description: "This feature is not available in the current version.", description:
"This feature is not available in the current version.",
}); });
return; return;
} }
const result = await api.autoMode.commitFeature(currentProject.path, feature.id); // Pass the feature's worktreePath to ensure commits happen in the correct worktree
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id,
feature.worktreePath
);
if (result.success) { if (result.success) {
moveFeature(feature.id, "verified"); moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" }); persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", { toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(feature.description)}`, description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
}); });
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else { } else {
console.error("[Board] Failed to commit feature:", result.error); console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", { toast.error("Failed to commit feature", {
@@ -389,49 +601,19 @@ export function useBoardActions({
} catch (error) { } catch (error) {
console.error("[Board] Error committing feature:", error); console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", { toast.error("Failed to commit feature", {
description: error instanceof Error ? error.message : "An error occurred", description:
error instanceof Error ? error.message : "An error occurred",
}); });
await loadFeatures(); await loadFeatures();
} }
}, },
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures] [
); currentProject,
moveFeature,
const handleRevertFeature = useCallback( persistFeatureUpdate,
async (feature: Feature) => { loadFeatures,
if (!currentProject) return; onWorktreeCreated,
]
try {
const api = getElectronAPI();
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description: "This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(currentProject.path, feature.id);
if (result.success) {
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
toast.error("Failed to revert feature", {
description: result.error || "An error occurred",
});
}
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description: error instanceof Error ? error.message : "An error occurred",
});
}
},
[currentProject, loadFeatures]
); );
const handleMergeFeature = useCallback( const handleMergeFeature = useCallback(
@@ -443,17 +625,23 @@ export function useBoardActions({
if (!api?.worktree?.mergeFeature) { if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available"); console.error("Worktree API not available");
toast.error("Merge not available", { toast.error("Merge not available", {
description: "This feature is not available in the current version.", description:
"This feature is not available in the current version.",
}); });
return; return;
} }
const result = await api.worktree.mergeFeature(currentProject.path, feature.id); const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
if (result.success) { if (result.success) {
await loadFeatures(); await loadFeatures();
toast.success("Feature merged", { toast.success("Feature merged", {
description: `Changes merged to main branch: ${truncateDescription(feature.description)}`, description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
}); });
} else { } else {
console.error("[Board] Failed to merge feature:", result.error); console.error("[Board] Failed to merge feature:", result.error);
@@ -464,7 +652,8 @@ export function useBoardActions({
} catch (error) { } catch (error) {
console.error("[Board] Error merging feature:", error); console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", { toast.error("Failed to merge feature", {
description: error instanceof Error ? error.message : "An error occurred", description:
error instanceof Error ? error.message : "An error occurred",
}); });
} }
}, },
@@ -495,7 +684,9 @@ export function useBoardActions({
persistFeatureUpdate(feature.id, updates); persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", { toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(feature.description)}`, description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
}); });
}, },
[updateFeature, persistFeatureUpdate] [updateFeature, persistFeatureUpdate]
@@ -524,7 +715,12 @@ export function useBoardActions({
setOutputFeature(targetFeature); setOutputFeature(targetFeature);
} }
}, },
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature] [
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
); );
const handleForceStopFeature = useCallback( const handleForceStopFeature = useCallback(
@@ -539,19 +735,25 @@ export function useBoardActions({
if (targetStatus !== feature.status) { if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus); moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus }); // Must await to ensure file is written before user can restart
await persistFeatureUpdate(feature.id, { status: targetStatus });
} }
toast.success("Agent stopped", { toast.success("Agent stopped", {
description: description:
targetStatus === "waiting_approval" targetStatus === "waiting_approval"
? `Stopped commit - returned to waiting approval: ${truncateDescription(feature.description)}` ? `Stopped commit - returned to waiting approval: ${truncateDescription(
: `Stopped working on: ${truncateDescription(feature.description)}`, feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
}); });
} catch (error) { } catch (error) {
console.error("[Board] Error stopping feature:", error); console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", { toast.error("Failed to stop agent", {
description: error instanceof Error ? error.message : "An error occurred", description:
error instanceof Error ? error.message : "An error occurred",
}); });
} }
}, },
@@ -559,7 +761,32 @@ export function useBoardActions({
); );
const handleStartNextFeatures = useCallback(async () => { const handleStartNextFeatures = useCallback(async () => {
const backlogFeatures = features.filter((f) => f.status === "backlog"); // Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
// Determine the feature's branch (default to "main" if not set)
const featureBranch = f.branchName || "main";
// If no worktree is selected (currentWorktreeBranch is null or main-like),
// show features with no branch or "main"/"master" branch
if (
!currentWorktreeBranch ||
currentWorktreeBranch === "main" ||
currentWorktreeBranch === "master"
) {
return (
!f.branchName ||
featureBranch === "main" ||
featureBranch === "master"
);
}
// Otherwise, only show features matching the selected worktree branch
return featureBranch === currentWorktreeBranch;
});
const availableSlots = const availableSlots =
useAppStore.getState().maxConcurrency - runningAutoTasks.length; useAppStore.getState().maxConcurrency - runningAutoTasks.length;
@@ -571,12 +798,56 @@ export function useBoardActions({
return; return;
} }
const featuresToStart = backlogFeatures.slice(0, availableSlots); if (backlogFeatures.length === 0) {
toast.info("Backlog empty", {
description:
currentWorktreeBranch &&
currentWorktreeBranch !== "main" &&
currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
});
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
// This matches the auto mode service behavior for consistency
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Start only one feature per keypress (user must press again for next)
const featuresToStart = sortedBacklog.slice(0, 1);
for (const feature of featuresToStart) { for (const feature of featuresToStart) {
await handleStartImplementation(feature); // Only create worktrees if the feature is enabled
let worktreePath: string | null = null;
if (useWorktrees) {
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
worktreePath = await getOrCreateWorktreeForFeature(feature);
if (worktreePath) {
await persistFeatureUpdate(feature.id, { worktreePath });
}
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
// Start the implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({
...feature,
worktreePath: worktreePath || undefined,
});
} }
}, [features, runningAutoTasks, handleStartImplementation]); }, [
features,
runningAutoTasks,
handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
currentWorktreeBranch,
useWorktrees,
]);
const handleDeleteAllVerified = useCallback(async () => { const handleDeleteAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified"); const verifiedFeatures = features.filter((f) => f.status === "verified");
@@ -587,10 +858,7 @@ export function useBoardActions({
try { try {
await autoMode.stopFeature(feature.id); await autoMode.stopFeature(feature.id);
} catch (error) { } catch (error) {
console.error( console.error("[Board] Error stopping feature before delete:", error);
"[Board] Error stopping feature before delete:",
error
);
} }
} }
removeFeature(feature.id); removeFeature(feature.id);
@@ -600,7 +868,13 @@ export function useBoardActions({
toast.success("All verified features deleted", { toast.success("All verified features deleted", {
description: `Deleted ${verifiedFeatures.length} feature(s).`, description: `Deleted ${verifiedFeatures.length} feature(s).`,
}); });
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]); }, [
features,
runningAutoTasks,
autoMode,
removeFeature,
persistFeatureDelete,
]);
return { return {
handleAddFeature, handleAddFeature,
@@ -614,7 +888,6 @@ export function useBoardActions({
handleOpenFollowUp, handleOpenFollowUp,
handleSendFollowUp, handleSendFollowUp,
handleCommitFeature, handleCommitFeature,
handleRevertFeature,
handleMergeFeature, handleMergeFeature,
handleCompleteFeature, handleCompleteFeature,
handleUnarchiveFeature, handleUnarchiveFeature,

View File

@@ -1,5 +1,7 @@
import { useMemo, useCallback } from "react"; import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store"; import { Feature } from "@/store/app-store";
import { resolveDependencies } from "@/lib/dependency-resolver";
import { pathsEqual } from "@/lib/utils";
type ColumnId = Feature["status"]; type ColumnId = Feature["status"];
@@ -7,12 +9,18 @@ interface UseBoardColumnFeaturesProps {
features: Feature[]; features: Feature[];
runningAutoTasks: string[]; runningAutoTasks: string[];
searchQuery: string; searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
projectPath: string | null; // Main project path (for main worktree)
} }
export function useBoardColumnFeatures({ export function useBoardColumnFeatures({
features, features,
runningAutoTasks, runningAutoTasks,
searchQuery, searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
}: UseBoardColumnFeaturesProps) { }: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders // Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => { const columnFeaturesMap = useMemo(() => {
@@ -34,16 +42,63 @@ export function useBoardColumnFeatures({
) )
: features; : features;
// Determine the effective worktree path and branch for filtering
// If currentWorktreePath is null, we're on the main worktree
const effectiveWorktreePath = currentWorktreePath || projectPath;
// Use the branch name from the selected worktree
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
// should contain the main branch's actual name, defaulting to "main"
// If we're selecting a non-main worktree but can't find it, currentWorktreeBranch is null
// In that case, we can't do branch-based filtering, so we'll handle it specially below
const effectiveBranch = currentWorktreeBranch;
filteredFeatures.forEach((f) => { filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress" // If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id); const isRunning = runningAutoTasks.includes(f.id);
// Check if feature matches the current worktree
// Match by worktreePath if set, OR by branchName if set
// Features with neither are considered unassigned (show on ALL worktrees)
const featureBranch = f.branchName || "main";
const hasWorktreeAssigned = f.worktreePath || f.branchName;
let matchesWorktree: boolean;
if (!hasWorktreeAssigned) {
// No worktree or branch assigned - show on ALL worktrees (unassigned)
matchesWorktree = true;
} else if (f.worktreePath) {
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
} else if (effectiveBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
// Show features assigned to main/master branch since we're on the main worktree.
matchesWorktree = featureBranch === "main" || featureBranch === "master";
} else {
// Has branchName but no worktreePath - match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
if (isRunning) { if (isRunning) {
map.in_progress.push(f); // Only show running tasks if they match the current worktree
if (matchesWorktree) {
map.in_progress.push(f);
}
} else { } else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses) // Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId; const status = f.status as ColumnId;
if (map[status]) {
map[status].push(f); // Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === "backlog") {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else { } else {
// Unknown status, default to backlog // Unknown status, default to backlog
map.backlog.push(f); map.backlog.push(f);
@@ -51,15 +106,16 @@ export function useBoardColumnFeatures({
} }
}); });
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority // Apply dependency-aware sorting to backlog
map.backlog.sort((a, b) => { // This ensures features appear in dependency order (dependencies before dependents)
const aPriority = a.priority ?? 999; // Features without priority go last // Within the same dependency level, features are sorted by priority
const bPriority = b.priority ?? 999; if (map.backlog.length > 0) {
return aPriority - bPriority; const { orderedFeatures } = resolveDependencies(map.backlog);
}); map.backlog = orderedFeatures;
}
return map; return map;
}, [features, runningAutoTasks, searchQuery]); }, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
const getColumnFeatures = useCallback( const getColumnFeatures = useCallback(
(columnId: ColumnId) => { (columnId: ColumnId) => {

View File

@@ -4,6 +4,7 @@ import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { toast } from "sonner"; import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants"; import { COLUMNS, ColumnId } from "../constants";
import { getElectronAPI } from "@/lib/electron";
interface UseBoardDragDropProps { interface UseBoardDragDropProps {
features: Feature[]; features: Feature[];
@@ -14,6 +15,8 @@ interface UseBoardDragDropProps {
updates: Partial<Feature> updates: Partial<Feature>
) => Promise<void>; ) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>; handleStartImplementation: (feature: Feature) => Promise<boolean>;
projectPath: string | null; // Main project path
onWorktreeCreated?: () => void; // Callback when a new worktree is created
} }
export function useBoardDragDrop({ export function useBoardDragDrop({
@@ -22,9 +25,66 @@ export function useBoardDragDrop({
runningAutoTasks, runningAutoTasks,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
projectPath,
onWorktreeCreated,
}: UseBoardDragDropProps) { }: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore(); const { moveFeature, useWorktrees } = useAppStore();
/**
* Get or create the worktree path for a feature based on its branchName.
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[DragDrop] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error("[DragDrop] Failed to create worktree:", result.error);
toast.error("Failed to create worktree", {
description: result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[DragDrop] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
@@ -97,8 +157,20 @@ export function useBoardDragDrop({
if (draggedFeature.status === "backlog") { if (draggedFeature.status === "backlog") {
// From backlog // From backlog
if (targetStatus === "in_progress") { if (targetStatus === "in_progress") {
// Only create worktrees if the feature is enabled
let worktreePath: string | null = null;
if (useWorktrees) {
// Get or create worktree based on the feature's assigned branch
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
if (worktreePath) {
await persistFeatureUpdate(featureId, { worktreePath });
}
// Refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
}
// Use helper function to handle concurrency check and start implementation // Use helper function to handle concurrency check and start implementation
await handleStartImplementation(draggedFeature); // Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
} else { } else {
moveFeature(featureId, targetStatus); moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus }); persistFeatureUpdate(featureId, { status: targetStatus });
@@ -123,10 +195,11 @@ export function useBoardDragDrop({
} 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 justFinishedAt timestamp when moving back to backlog // Clear justFinishedAt timestamp and worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { persistFeatureUpdate(featureId, {
status: "backlog", status: "backlog",
justFinishedAt: undefined, justFinishedAt: undefined,
worktreePath: 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(
@@ -166,7 +239,8 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog // Allow moving skipTests cards back to backlog
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" }); // Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: 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,
@@ -189,7 +263,8 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog // Allow moving verified cards back to backlog
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" }); // Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: 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,
@@ -205,6 +280,9 @@ export function useBoardDragDrop({
moveFeature, moveFeature,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, handleStartImplementation,
getOrCreateWorktreeForFeature,
onWorktreeCreated,
useWorktrees,
] ]
); );

View File

@@ -45,8 +45,6 @@ interface KanbanBoardProps {
onMoveBackToInProgress: (feature: Feature) => void; onMoveBackToInProgress: (feature: Feature) => void;
onFollowUp: (feature: Feature) => void; onFollowUp: (feature: Feature) => void;
onCommit: (feature: Feature) => void; onCommit: (feature: Feature) => void;
onRevert: (feature: Feature) => void;
onMerge: (feature: Feature) => void;
onComplete: (feature: Feature) => void; onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void; onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void; onViewPlan: (feature: Feature) => void;
@@ -79,8 +77,6 @@ export function KanbanBoard({
onMoveBackToInProgress, onMoveBackToInProgress,
onFollowUp, onFollowUp,
onCommit, onCommit,
onRevert,
onMerge,
onComplete, onComplete,
onImplement, onImplement,
onViewPlan, onViewPlan,
@@ -195,8 +191,6 @@ export function KanbanBoard({
} }
onFollowUp={() => onFollowUp(feature)} onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)} onCommit={() => onCommit(feature)}
onRevert={() => onRevert(feature)}
onMerge={() => onMerge(feature)}
onComplete={() => onComplete(feature)} onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)} onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)} onViewPlan={() => onViewPlan(feature)}

View File

@@ -0,0 +1,123 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
GitBranch,
RefreshCw,
GitBranchPlus,
Check,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo } from "../types";
interface BranchSwitchDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onOpenChange: (open: boolean) => void;
onFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
}
export function BranchSwitchDropdown({
worktree,
isSelected,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onOpenChange,
onFilterChange,
onSwitchBranch,
onCreateBranch,
}: BranchSwitchDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => onFilterChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => onSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
))
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,3 @@
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
export { WorktreeTab } from "./worktree-tab";

View File

@@ -0,0 +1,194 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
Square,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo } from "../types";
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
isPushing,
isStartingDevServer,
isDevServerRunning,
devServerInfo,
onOpenChange,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeActionsDropdownProps) {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{isDevServerRunning ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => onStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onPull(worktree)}
disabled={isPulling}
className="text-xs"
>
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onPush(worktree)}
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
<GitCommit className="w-3.5 h-3.5 mr-2" />
Commit Changes
</DropdownMenuItem>
)}
{(worktree.branch !== "main" || worktree.hasChanges) && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete Worktree
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
interface WorktreeTabProps {
worktree: WorktreeInfo;
isSelected: boolean;
isRunning: boolean;
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
}
export function WorktreeTab({
worktree,
isSelected,
isRunning,
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
onPull,
onPush,
onOpenInEditor,
onCommit,
onCreatePR,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
return (
<div className="flex items-center">
{worktree.isMain ? (
<>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
<BranchSwitchDropdown
worktree={worktree}
isSelected={isSelected}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
</>
) : (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70"
)}
onClick={() => onSelectWorktree(worktree)}
disabled={isActivating}
title={
worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && (
<RefreshCw className="w-3 h-3 animate-spin" />
)}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
)}
{isDevServerRunning && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
/>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { useWorktrees } from "./use-worktrees";
export { useDevServers } from "./use-dev-servers";
export { useBranches } from "./use-branches";
export { useWorktreeActions } from "./use-worktree-actions";
export { useDefaultEditor } from "./use-default-editor";
export { useRunningFeatures } from "./use-running-features";

View File

@@ -0,0 +1,54 @@
"use client";
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import type { BranchInfo } from "../types";
export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
console.warn("List branches API not available");
return;
}
const result = await api.worktree.listBranches(worktreePath);
if (result.success && result.result) {
setBranches(result.result.branches);
setAheadCount(result.result.aheadCount || 0);
setBehindCount(result.result.behindCount || 0);
}
} catch (error) {
console.error("Failed to fetch branches:", error);
} finally {
setIsLoadingBranches(false);
}
}, []);
const resetBranchFilter = useCallback(() => {
setBranchFilter("");
}, []);
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
return {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
};
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
export function useDefaultEditor() {
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const fetchDefaultEditor = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getDefaultEditor) {
return;
}
const result = await api.worktree.getDefaultEditor();
if (result.success && result.result?.editorName) {
setDefaultEditorName(result.result.editorName);
}
} catch (error) {
console.error("Failed to fetch default editor:", error);
}
}, []);
useEffect(() => {
fetchDefaultEditor();
}, [fetchDefaultEditor]);
return {
defaultEditorName,
};
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { normalizePath } from "@/lib/utils";
import { toast } from "sonner";
import type { DevServerInfo, WorktreeInfo } from "../types";
interface UseDevServersOptions {
projectPath: string;
}
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(
new Map()
);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
useEffect(() => {
fetchDevServers();
}, [fetchDevServers]);
const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
return path ? normalizePath(path) : path;
},
[projectPath]
);
const handleStartDevServer = useCallback(
async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
},
[isStartingDevServer, projectPath]
);
const handleStopDevServer = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
},
[projectPath]
);
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
},
[projectPath, runningDevServers]
);
const isDevServerRunning = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.has(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
const getDevServerInfo = useCallback(
(worktree: WorktreeInfo) => {
return runningDevServers.get(getWorktreeKey(worktree));
},
[runningDevServers, getWorktreeKey]
);
return {
isStartingDevServer,
runningDevServers,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
};
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useCallback } from "react";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo, FeatureInfo } from "../types";
interface UseRunningFeaturesOptions {
projectPath: string;
runningFeatureIds: string[];
features: FeatureInfo[];
getWorktreeKey: (worktree: WorktreeInfo) => string;
}
export function useRunningFeatures({
projectPath,
runningFeatureIds,
features,
getWorktreeKey,
}: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
const worktreeKey = getWorktreeKey(worktree);
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
if (feature.worktreePath) {
if (worktree.isMain) {
return pathsEqual(feature.worktreePath, projectPath);
}
return pathsEqual(feature.worktreePath, worktreeKey);
}
if (feature.branchName) {
return worktree.branch === feature.branchName;
}
return worktree.isMain;
});
},
[runningFeatureIds, features, projectPath, getWorktreeKey]
);
return {
hasRunningFeatures,
};
}

View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<void>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({
fetchWorktrees,
fetchBranches,
}: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.switchBranch) {
toast.error("Switch branch API not available");
return;
}
const result = await api.worktree.switchBranch(worktree.path, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsSwitching(false);
}
},
[isSwitching, fetchWorktrees]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
toast.error("Pull API not available");
return;
}
const result = await api.worktree.pull(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to pull latest changes");
}
} catch (error) {
console.error("Pull failed:", error);
toast.error("Failed to pull latest changes");
} finally {
setIsPulling(false);
}
},
[isPulling, fetchWorktrees]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error("Push API not available");
return;
}
const result = await api.worktree.push(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to push changes");
}
} catch (error) {
console.error("Push failed:", error);
toast.error("Failed to push changes");
} finally {
setIsPushing(false);
}
},
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
}, []);
return {
isPulling,
isPushing,
isSwitching,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
};
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo } from "../types";
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
}
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
if (!projectPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
console.warn("Worktree API not available");
return;
}
const result = await api.worktree.listAll(projectPath, true);
if (result.success && result.worktrees) {
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
} catch (error) {
console.error("Failed to fetch worktrees:", error);
} finally {
setIsLoading(false);
}
}, [projectPath, setWorktreesInStore]);
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
}
}, [refreshTrigger, fetchWorktrees]);
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
},
[projectPath, setCurrentWorktree]
);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
return {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
selectedWorktree,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
};
}

View File

@@ -0,0 +1,8 @@
export { WorktreePanel } from "./worktree-panel";
export type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
FeatureInfo,
WorktreePanelProps,
} from "./types";

View File

@@ -0,0 +1,39 @@
export interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean;
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
export interface FeatureInfo {
id: string;
worktreePath?: string;
branchName?: string;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
refreshTrigger?: number;
}

View File

@@ -0,0 +1,177 @@
"use client";
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw } from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from "./hooks";
import { WorktreeTab } from "./components";
export function WorktreePanel({
projectPath,
onCreateWorktree,
onDeleteWorktree,
onCommit,
onCreatePR,
onCreateBranch,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
}: WorktreePanelProps) {
const {
isLoading,
worktrees,
currentWorktree,
currentWorktreePath,
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
} = useWorktrees({ projectPath, refreshTrigger });
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
handleStopDevServer,
handleOpenDevServerUrl,
} = useDevServers({ projectPath });
const {
branches,
filteredBranches,
aheadCount,
behindCount,
isLoadingBranches,
branchFilter,
setBranchFilter,
resetBranchFilter,
fetchBranches,
} = useBranches();
const {
isPulling,
isPushing,
isSwitching,
isActivating,
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInEditor,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
projectPath,
runningFeatureIds,
features,
getWorktreeKey,
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
if (!useWorktreesEnabled) {
return null;
}
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => (
<WorktreeTab
key={worktree.path}
worktree={worktree}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
))}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={fetchWorktrees}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
BookOpen, BookOpen,
EditIcon, EditIcon,
Eye, Eye,
Pencil,
} from "lucide-react"; } from "lucide-react";
import { import {
useKeyboardShortcuts, useKeyboardShortcuts,
@@ -56,6 +57,8 @@ export function ContextView() {
const [editedContent, setEditedContent] = useState(""); const [editedContent, setEditedContent] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState("");
const [newFileName, setNewFileName] = useState(""); const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text"); const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>( const [uploadedImageData, setUploadedImageData] = useState<string | null>(
@@ -240,6 +243,60 @@ export function ContextView() {
} }
}; };
// Rename selected file
const handleRenameFile = async () => {
const contextPath = getContextPath();
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
const newName = renameFileName.trim();
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${contextPath}/${newName}`;
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error("A file with this name already exists");
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error("Failed to read file for rename");
return;
}
// Write to new path
await api.writeFile(newPath, result.content);
// Delete old file
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName("");
// Reload files and select the renamed file
await loadContextFiles();
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? "image" : "text",
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
console.error("Failed to rename file:", error);
}
};
// Handle image upload // Handle image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -418,24 +475,40 @@ export function ContextView() {
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{contextFiles.map((file) => ( {contextFiles.map((file) => (
<button <div
key={file.path} key={file.path}
onClick={() => handleSelectFile(file)}
className={cn( className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors", "group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
selectedFile?.path === file.path selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30" ? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground"
)} )}
data-testid={`context-file-${file.name}`}
> >
{file.type === "image" ? ( <button
<ImageIcon className="w-4 h-4 flex-shrink-0" /> onClick={() => handleSelectFile(file)}
) : ( className="flex-1 flex items-center gap-2 text-left min-w-0"
<FileText className="w-4 h-4 flex-shrink-0" /> data-testid={`context-file-${file.name}`}
)} >
<span className="truncate text-sm">{file.name}</span> {file.type === "image" ? (
</button> <ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
data-testid={`rename-context-file-${file.name}`}
>
<Pencil className="w-3 h-3" />
</button>
</div>
))} ))}
</div> </div>
)} )}
@@ -730,6 +803,53 @@ export function ContextView() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent data-testid="rename-context-dialog">
<DialogHeader>
<DialogTitle>Rename Context File</DialogTitle>
<DialogDescription>
Enter a new name for "{selectedFile?.name}".
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
<Label htmlFor="rename-filename">File Name</Label>
<Input
id="rename-filename"
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === "Enter" && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName("");
}}
>
Cancel
</Button>
<Button
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
data-testid="confirm-rename-file"
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -30,6 +30,8 @@ export function SettingsView() {
setProjectTheme, setProjectTheme,
defaultSkipTests, defaultSkipTests,
setDefaultSkipTests, setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
useWorktrees, useWorktrees,
setUseWorktrees, setUseWorktrees,
showProfilesOnly, showProfilesOnly,
@@ -122,11 +124,13 @@ export function SettingsView() {
<FeatureDefaultsSection <FeatureDefaultsSection
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests} defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
useWorktrees={useWorktrees} useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode} defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval} defaultRequirePlanApproval={defaultRequirePlanApproval}
onShowProfilesOnlyChange={setShowProfilesOnly} onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests} onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onUseWorktreesChange={setUseWorktrees} onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}

View File

@@ -1,7 +1,7 @@
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
FlaskConical, Settings2, TestTube, GitBranch, FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
Zap, ClipboardList, FileText, ScrollText, ShieldCheck Zap, ClipboardList, FileText, ScrollText, ShieldCheck
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -18,11 +18,13 @@ type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface FeatureDefaultsSectionProps { interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean; showProfilesOnly: boolean;
defaultSkipTests: boolean; defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean; useWorktrees: boolean;
defaultPlanningMode: PlanningMode; defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean; defaultRequirePlanApproval: boolean;
onShowProfilesOnlyChange: (value: boolean) => void; onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void; onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void;
@@ -31,11 +33,13 @@ interface FeatureDefaultsSectionProps {
export function FeatureDefaultsSection({ export function FeatureDefaultsSection({
showProfilesOnly, showProfilesOnly,
defaultSkipTests, defaultSkipTests,
enableDependencyBlocking,
useWorktrees, useWorktrees,
defaultPlanningMode, defaultPlanningMode,
defaultRequirePlanApproval, defaultRequirePlanApproval,
onShowProfilesOnlyChange, onShowProfilesOnlyChange,
onDefaultSkipTestsChange, onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onUseWorktreesChange, onUseWorktreesChange,
onDefaultPlanningModeChange, onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange, onDefaultRequirePlanApprovalChange,
@@ -224,22 +228,51 @@ export function FeatureDefaultsSection({
{/* Separator */} {/* Separator */}
<div className="border-t border-border/30" /> <div className="border-t border-border/30" />
{/* Dependency Blocking Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="enable-dependency-blocking"
checked={enableDependencyBlocking}
onCheckedChange={(checked) =>
onEnableDependencyBlockingChange(checked === true)
}
className="mt-1"
data-testid="enable-dependency-blocking-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-dependency-blocking"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<AlertCircle className="w-4 h-4 text-brand-500" />
Enable Dependency Blocking
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, features with incomplete dependencies will show blocked badges
and warnings. Auto mode and backlog ordering always respect dependencies
regardless of this setting.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */} {/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl transition-colors duration-200 -mx-3 opacity-60"> <div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox <Checkbox
id="use-worktrees" id="use-worktrees"
checked={useWorktrees} checked={useWorktrees}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onUseWorktreesChange(checked === true) onUseWorktreesChange(checked === true)
} }
disabled={true}
className="mt-1" className="mt-1"
data-testid="use-worktrees-checkbox" data-testid="use-worktrees-checkbox"
/> />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label <Label
htmlFor="use-worktrees" htmlFor="use-worktrees"
className="text-foreground font-medium flex items-center gap-2" className="text-foreground cursor-pointer font-medium flex items-center gap-2"
> >
<GitBranch className="w-4 h-4 text-brand-500" /> <GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation Enable Git Worktree Isolation
@@ -251,9 +284,6 @@ export function FeatureDefaultsSection({
Creates isolated git branches for each feature. When disabled, Creates isolated git branches for each feature. When disabled,
agents work directly in the main project directory. agents work directly in the main project directory.
</p> </p>
<p className="text-xs text-orange-500/80 leading-relaxed font-medium">
This feature is still under development and temporarily disabled.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -30,7 +30,10 @@ export type Theme =
| "catppuccin" | "catppuccin"
| "onedark" | "onedark"
| "synthwave" | "synthwave"
| "red"; | "red"
| "cream"
| "sunset"
| "gray";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed"; export type KanbanDetailLevel = "minimal" | "standard" | "detailed";

View File

@@ -7,6 +7,7 @@ import {
WelcomeStep, WelcomeStep,
CompleteStep, CompleteStep,
ClaudeSetupStep, ClaudeSetupStep,
GitHubSetupStep,
} from "./setup-view/steps"; } from "./setup-view/steps";
// Main Setup View // Main Setup View
@@ -19,12 +20,13 @@ export function SetupView() {
} = useSetupStore(); } = useSetupStore();
const { setCurrentView } = useAppStore(); const { setCurrentView } = useAppStore();
const steps = ["welcome", "claude", "complete"] as const; const steps = ["welcome", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number]; type StepName = (typeof steps)[number];
const getStepName = (): StepName => { const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth") if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude"; return "claude";
if (currentStep === "welcome") return "welcome"; if (currentStep === "welcome") return "welcome";
if (currentStep === "github") return "github";
return "complete"; return "complete";
}; };
const currentIndex = steps.indexOf(getStepName()); const currentIndex = steps.indexOf(getStepName());
@@ -42,6 +44,10 @@ export function SetupView() {
setCurrentStep("claude_detect"); setCurrentStep("claude_detect");
break; break;
case "claude": case "claude":
console.log("[Setup Flow] Moving to github step");
setCurrentStep("github");
break;
case "github":
console.log("[Setup Flow] Moving to complete step"); console.log("[Setup Flow] Moving to complete step");
setCurrentStep("complete"); setCurrentStep("complete");
break; break;
@@ -54,12 +60,20 @@ export function SetupView() {
case "claude": case "claude":
setCurrentStep("welcome"); setCurrentStep("welcome");
break; break;
case "github":
setCurrentStep("claude_detect");
break;
} }
}; };
const handleSkipClaude = () => { const handleSkipClaude = () => {
console.log("[Setup Flow] Skipping Claude setup"); console.log("[Setup Flow] Skipping Claude setup");
setSkipClaudeSetup(true); setSkipClaudeSetup(true);
setCurrentStep("github");
};
const handleSkipGithub = () => {
console.log("[Setup Flow] Skipping GitHub setup");
setCurrentStep("complete"); setCurrentStep("complete");
}; };
@@ -110,6 +124,14 @@ export function SetupView() {
/> />
)} )}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && ( {currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} /> <CompleteStep onFinish={handleFinish} />
)} )}

View File

@@ -0,0 +1,333 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import {
CheckCircle2,
Loader2,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
RefreshCw,
AlertTriangle,
Github,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "../components";
interface GitHubSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
export function GitHubSetupStep({
onNext,
onBack,
onSkip,
}: GitHubSetupStepProps) {
const { ghCliStatus, setGhCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getGhStatus) {
return;
}
const result = await api.setup.getGhStatus();
if (result.success) {
setGhCliStatus({
installed: result.installed,
authenticated: result.authenticated,
version: result.version,
path: result.path,
user: result.user,
});
}
} catch (error) {
console.error("Failed to check gh status:", error);
} finally {
setIsChecking(false);
}
}, [setGhCliStatus]);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
};
const isReady = ghCliStatus?.installed && ghCliStatus?.authenticated;
const getStatusBadge = () => {
if (isChecking) {
return <StatusBadge status="checking" label="Checking..." />;
}
if (ghCliStatus?.authenticated) {
return <StatusBadge status="authenticated" label="Ready" />;
}
if (ghCliStatus?.installed) {
return <StatusBadge status="unverified" label="Not Logged In" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Github className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
GitHub CLI Setup
</h2>
<p className="text-muted-foreground">
Optional - Used for creating pull requests
</p>
</div>
{/* Info Banner */}
<Card className="bg-amber-500/10 border-amber-500/20">
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-foreground">
This step is optional
</p>
<p className="text-sm text-muted-foreground mt-1">
The GitHub CLI allows you to create pull requests directly from
the app. Without it, you can still create PRs manually in your
browser.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Github className="w-5 h-5" />
GitHub CLI Status
</CardTitle>
<div className="flex items-center gap-2">
{getStatusBadge()}
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
<CardDescription>
{ghCliStatus?.installed
? ghCliStatus.authenticated
? `Logged in${ghCliStatus.user ? ` as ${ghCliStatus.user}` : ""}`
: "Installed but not logged in"
: "Not installed on your system"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State */}
{isReady && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
GitHub CLI is ready!
</p>
<p className="text-sm text-muted-foreground">
You can create pull requests directly from the app.
{ghCliStatus?.version && (
<span className="ml-1">Version: {ghCliStatus.version}</span>
)}
</p>
</div>
</div>
)}
{/* Not Installed */}
{!ghCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not found
</p>
<p className="text-sm text-muted-foreground mt-1">
Install the GitHub CLI to enable PR creation from the app.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">
Installation Commands:
</p>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">macOS (Homebrew)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
brew install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("brew install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Windows (winget)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
winget install GitHub.cli
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("winget install GitHub.cli")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Linux (apt)</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
sudo apt install gh
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("sudo apt install gh")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<a
href="https://cli.github.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
>
View all installation options
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{/* Installed but not authenticated */}
{ghCliStatus?.installed && !ghCliStatus?.authenticated && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">
GitHub CLI not logged in
</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate with GitHub.
</p>
</div>
</div>
<div className="space-y-2 p-4 rounded-lg bg-muted/30 border border-border">
<p className="text-sm text-muted-foreground">
Run this command in your terminal:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
gh auth login
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("gh auth login")}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Checking GitHub CLI status...
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
{isReady ? "Skip" : "Skip for now"}
</Button>
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="github-next-button"
>
{isReady ? "Continue" : "Continue without GitHub CLI"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,3 +2,4 @@
export { WelcomeStep } from "./welcome-step"; export { WelcomeStep } from "./welcome-step";
export { CompleteStep } from "./complete-step"; export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step"; export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";

View File

@@ -356,6 +356,81 @@ const redTheme: TerminalTheme = {
brightWhite: "#d0c0c0", brightWhite: "#d0c0c0",
}; };
// Cream theme - Warm, soft, easy on the eyes
const creamTheme: TerminalTheme = {
background: "#f5f3ee",
foreground: "#5a4a3a",
cursor: "#9d6b53",
cursorAccent: "#f5f3ee",
selectionBackground: "#d4c4b0",
black: "#5a4a3a",
red: "#c85a4f",
green: "#7a9a6a",
yellow: "#c9a554",
blue: "#6b8aaa",
magenta: "#a66a8a",
cyan: "#5a9a8a",
white: "#b0a090",
brightBlack: "#8a7a6a",
brightRed: "#e07060",
brightGreen: "#90b080",
brightYellow: "#e0bb70",
brightBlue: "#80a0c0",
brightMagenta: "#c080a0",
brightCyan: "#70b0a0",
brightWhite: "#d0c0b0",
};
// Sunset theme - Mellow oranges and soft pastels
const sunsetTheme: TerminalTheme = {
background: "#1e1a24",
foreground: "#f2e8dd",
cursor: "#dd8855",
cursorAccent: "#1e1a24",
selectionBackground: "#3a2a40",
black: "#1e1a24",
red: "#dd6655",
green: "#88bb77",
yellow: "#ddaa66",
blue: "#6699cc",
magenta: "#cc7799",
cyan: "#66ccaa",
white: "#e8d8c8",
brightBlack: "#4a3a50",
brightRed: "#ee8866",
brightGreen: "#99cc88",
brightYellow: "#eebb77",
brightBlue: "#88aadd",
brightMagenta: "#dd88aa",
brightCyan: "#88ddbb",
brightWhite: "#f5e8dd",
};
// Gray theme - Modern, minimal gray scheme inspired by Cursor
const grayTheme: TerminalTheme = {
background: "#2a2d32",
foreground: "#d0d0d5",
cursor: "#8fa0c0",
cursorAccent: "#2a2d32",
selectionBackground: "#3a3f48",
black: "#2a2d32",
red: "#d87070",
green: "#78b088",
yellow: "#d0b060",
blue: "#7090c0",
magenta: "#a880b0",
cyan: "#60a0b0",
white: "#b0b0b8",
brightBlack: "#606068",
brightRed: "#e88888",
brightGreen: "#90c8a0",
brightYellow: "#e0c878",
brightBlue: "#90b0d8",
brightMagenta: "#c098c8",
brightCyan: "#80b8c8",
brightWhite: "#e0e0e8",
};
// Theme mapping // Theme mapping
const terminalThemes: Record<ThemeMode, TerminalTheme> = { const terminalThemes: Record<ThemeMode, TerminalTheme> = {
light: lightTheme, light: lightTheme,
@@ -372,6 +447,9 @@ const terminalThemes: Record<ThemeMode, TerminalTheme> = {
onedark: onedarkTheme, onedark: onedarkTheme,
synthwave: synthwaveTheme, synthwave: synthwaveTheme,
red: redTheme, red: redTheme,
cream: creamTheme,
sunset: sunsetTheme,
gray: grayTheme,
}; };
/** /**

View File

@@ -2,6 +2,8 @@ import {
type LucideIcon, type LucideIcon,
Atom, Atom,
Cat, Cat,
CloudSun,
Coffee,
Eclipse, Eclipse,
Flame, Flame,
Ghost, Ghost,
@@ -10,6 +12,7 @@ import {
Radio, Radio,
Snowflake, Snowflake,
Sparkles, Sparkles,
Square,
Sun, Sun,
Terminal, Terminal,
Trees, Trees,
@@ -92,4 +95,22 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
Icon: Heart, Icon: Heart,
testId: "red-mode-button", testId: "red-mode-button",
}, },
{
value: "cream",
label: "Cream",
Icon: Coffee,
testId: "cream-mode-button",
},
{
value: "sunset",
label: "Sunset",
Icon: CloudSun,
testId: "sunset-mode-button",
},
{
value: "gray",
label: "Gray",
Icon: Square,
testId: "gray-mode-button",
},
]; ];

View File

@@ -7,6 +7,7 @@ import { getElectronAPI } from "@/lib/electron";
interface UseElectronAgentOptions { interface UseElectronAgentOptions {
sessionId: string; sessionId: string;
workingDirectory?: string; workingDirectory?: string;
model?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void; onToolUse?: (toolName: string, toolInput: unknown) => void;
} }
@@ -33,6 +34,7 @@ interface UseElectronAgentResult {
export function useElectronAgent({ export function useElectronAgent({
sessionId, sessionId,
workingDirectory, workingDirectory,
model,
onToolUse, onToolUse,
}: UseElectronAgentOptions): UseElectronAgentResult { }: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
@@ -88,7 +90,8 @@ export function useElectronAgent({
sessionId, sessionId,
content, content,
workingDirectory, workingDirectory,
imagePaths imagePaths,
model
); );
if (!result.success) { if (!result.success) {
@@ -104,7 +107,7 @@ export function useElectronAgent({
throw err; throw err;
} }
}, },
[sessionId, workingDirectory, isProcessing] [sessionId, workingDirectory, model, isProcessing]
); );
// Message queue for queuing messages when agent is busy // Message queue for queuing messages when agent is busy
@@ -344,7 +347,8 @@ export function useElectronAgent({
sessionId, sessionId,
content, content,
workingDirectory, workingDirectory,
imagePaths imagePaths,
model
); );
if (!result.success) { if (!result.success) {
@@ -359,7 +363,7 @@ export function useElectronAgent({
setIsProcessing(false); setIsProcessing(false);
} }
}, },
[sessionId, workingDirectory, isProcessing] [sessionId, workingDirectory, model, isProcessing]
); );
// Stop current execution // Stop current execution

View File

@@ -68,6 +68,13 @@ function isInputFocused(): boolean {
return true; return true;
} }
// Check for any open dropdown menus (Radix UI uses role="menu")
// This prevents shortcuts from firing when user is typing in dropdown filters
const dropdownMenu = document.querySelector('[role="menu"]');
if (dropdownMenu) {
return true;
}
return false; return false;
} }

View File

@@ -130,9 +130,16 @@ function getCurrentPhase(content: string): "planning" | "action" | "verification
/** /**
* Extracts a summary from completed feature context * Extracts a summary from completed feature context
* Looks for content between <summary> and </summary> tags
*/ */
function extractSummary(content: string): string | undefined { function extractSummary(content: string): string | undefined {
// Look for summary sections - capture everything including subsections (###) // Look for <summary> tags - capture everything between opening and closing tags
const summaryTagMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i);
if (summaryTagMatch) {
return summaryTagMatch[1].trim();
}
// Fallback: Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end // Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i); const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) { if (summaryMatch) {

View File

@@ -0,0 +1,221 @@
/**
* Dependency Resolution Utility
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "@/store/app-store";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter(depId => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -158,7 +158,10 @@ export interface SpecRegenerationAPI {
analyzeProject?: boolean, analyzeProject?: boolean,
maxFeatures?: number maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{ generateFeatures: (
projectPath: string,
maxFeatures?: number
) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
@@ -224,7 +227,8 @@ export interface AutoModeAPI {
runFeature: ( runFeature: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees?: boolean useWorktrees?: boolean,
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>; ) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
verifyFeature: ( verifyFeature: (
projectPath: string, projectPath: string,
@@ -232,7 +236,8 @@ export interface AutoModeAPI {
) => Promise<{ success: boolean; passes?: boolean; error?: string }>; ) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
resumeFeature: ( resumeFeature: (
projectPath: string, projectPath: string,
featureId: string featureId: string,
useWorktrees?: boolean
) => Promise<{ success: boolean; passes?: boolean; error?: string }>; ) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
contextExists: ( contextExists: (
projectPath: string, projectPath: string,
@@ -245,11 +250,13 @@ export interface AutoModeAPI {
projectPath: string, projectPath: string,
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[] imagePaths?: string[],
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>; ) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
commitFeature: ( commitFeature: (
projectPath: string, projectPath: string,
featureId: string featureId: string,
worktreePath?: string
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
approvePlan: ( approvePlan: (
projectPath: string, projectPath: string,
@@ -324,7 +331,11 @@ export interface ElectronAPI {
features?: FeaturesAPI; features?: FeaturesAPI;
runningAgents?: RunningAgentsAPI; runningAgents?: RunningAgentsAPI;
enhancePrompt?: { enhancePrompt?: {
enhance: (originalText: string, enhancementMode: string, model?: string) => Promise<{ enhance: (
originalText: string,
enhancementMode: string,
model?: string
) => Promise<{
success: boolean; success: boolean;
enhancedText?: string; enhancedText?: string;
error?: string; error?: string;
@@ -391,6 +402,15 @@ export interface ElectronAPI {
authenticated: boolean; authenticated: boolean;
error?: string; error?: string;
}>; }>;
getGhStatus?: () => Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void; onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void;
}; };
@@ -407,7 +427,8 @@ export interface ElectronAPI {
sessionId: string, sessionId: string,
message: string, message: string,
workingDirectory?: string, workingDirectory?: string,
imagePaths?: string[] imagePaths?: string[],
model?: string
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
getHistory: (sessionId: string) => Promise<{ getHistory: (sessionId: string) => Promise<{
success: boolean; success: boolean;
@@ -913,6 +934,15 @@ interface SetupAPI {
authenticated: boolean; authenticated: boolean;
error?: string; error?: string;
}>; }>;
getGhStatus?: () => Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void; onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void;
} }
@@ -999,6 +1029,18 @@ function createMockSetupAPI(): SetupAPI {
}; };
}, },
getGhStatus: async () => {
console.log("[Mock] Getting GitHub CLI status");
return {
success: true,
installed: false,
authenticated: false,
version: null,
path: null,
user: null,
};
},
onInstallProgress: (callback) => { onInstallProgress: (callback) => {
// Mock progress events // Mock progress events
return () => {}; return () => {};
@@ -1014,11 +1056,6 @@ function createMockSetupAPI(): SetupAPI {
// Mock Worktree API implementation // Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI { function createMockWorktreeAPI(): WorktreeAPI {
return { return {
revertFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Reverting feature:", { projectPath, featureId });
return { success: true, removedPath: `/mock/worktree/${featureId}` };
},
mergeFeature: async ( mergeFeature: async (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
@@ -1064,6 +1101,106 @@ function createMockWorktreeAPI(): WorktreeAPI {
return { success: true, worktrees: [] }; return { success: true, worktrees: [] };
}, },
listAll: async (projectPath: string, includeDetails?: boolean) => {
console.log("[Mock] Listing all worktrees:", {
projectPath,
includeDetails,
});
return {
success: true,
worktrees: [
{
path: projectPath,
branch: "main",
isMain: true,
isCurrent: true,
hasWorktree: true,
hasChanges: false,
changedFilesCount: 0,
},
],
};
},
create: async (
projectPath: string,
branchName: string,
baseBranch?: string
) => {
console.log("[Mock] Creating worktree:", {
projectPath,
branchName,
baseBranch,
});
return {
success: true,
worktree: {
path: `${projectPath}/.worktrees/${branchName}`,
branch: branchName,
isNew: true,
},
};
},
delete: async (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) => {
console.log("[Mock] Deleting worktree:", {
projectPath,
worktreePath,
deleteBranch,
});
return {
success: true,
deleted: {
worktreePath,
branch: deleteBranch ? "feature-branch" : null,
},
};
},
commit: async (worktreePath: string, message: string) => {
console.log("[Mock] Committing changes:", { worktreePath, message });
return {
success: true,
result: {
committed: true,
commitHash: "abc123",
branch: "feature-branch",
message,
},
};
},
push: async (worktreePath: string, force?: boolean) => {
console.log("[Mock] Pushing worktree:", { worktreePath, force });
return {
success: true,
result: {
branch: "feature-branch",
pushed: true,
message: "Successfully pushed to origin/feature-branch",
},
};
},
createPR: async (worktreePath: string, options?: any) => {
console.log("[Mock] Creating PR:", { worktreePath, options });
return {
success: true,
result: {
branch: "feature-branch",
committed: true,
commitHash: "abc123",
pushed: true,
prUrl: "https://github.com/example/repo/pull/1",
prCreated: true,
},
};
},
getDiffs: async (projectPath: string, featureId: string) => { getDiffs: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting file diffs:", { projectPath, featureId }); console.log("[Mock] Getting file diffs:", { projectPath, featureId });
return { return {
@@ -1093,6 +1230,129 @@ function createMockWorktreeAPI(): WorktreeAPI {
filePath, filePath,
}; };
}, },
pull: async (worktreePath: string) => {
console.log("[Mock] Pulling latest changes for:", worktreePath);
return {
success: true,
result: {
branch: "main",
pulled: true,
message: "Pulled latest changes",
},
};
},
checkoutBranch: async (worktreePath: string, branchName: string) => {
console.log("[Mock] Creating and checking out branch:", {
worktreePath,
branchName,
});
return {
success: true,
result: {
previousBranch: "main",
newBranch: branchName,
message: `Created and checked out branch '${branchName}'`,
},
};
},
listBranches: async (worktreePath: string) => {
console.log("[Mock] Listing branches for:", worktreePath);
return {
success: true,
result: {
currentBranch: "main",
branches: [
{ name: "main", isCurrent: true, isRemote: false },
{ name: "develop", isCurrent: false, isRemote: false },
{ name: "feature/example", isCurrent: false, isRemote: false },
],
aheadCount: 2,
behindCount: 0,
},
};
},
switchBranch: async (worktreePath: string, branchName: string) => {
console.log("[Mock] Switching to branch:", { worktreePath, branchName });
return {
success: true,
result: {
previousBranch: "main",
currentBranch: branchName,
message: `Switched to branch '${branchName}'`,
},
};
},
openInEditor: async (worktreePath: string) => {
console.log("[Mock] Opening in editor:", worktreePath);
return {
success: true,
result: {
message: `Opened ${worktreePath} in VS Code`,
editorName: "VS Code",
},
};
},
getDefaultEditor: async () => {
console.log("[Mock] Getting default editor");
return {
success: true,
result: {
editorName: "VS Code",
editorCommand: "code",
},
};
},
initGit: async (projectPath: string) => {
console.log("[Mock] Initializing git:", projectPath);
return {
success: true,
result: {
initialized: true,
message: `Initialized git repository in ${projectPath}`,
},
};
},
startDevServer: async (projectPath: string, worktreePath: string) => {
console.log("[Mock] Starting dev server:", { projectPath, worktreePath });
return {
success: true,
result: {
worktreePath,
port: 3001,
url: "http://localhost:3001",
message: "Dev server started on port 3001",
},
};
},
stopDevServer: async (worktreePath: string) => {
console.log("[Mock] Stopping dev server:", worktreePath);
return {
success: true,
result: {
worktreePath,
message: "Dev server stopped",
},
};
},
listDevServers: async () => {
console.log("[Mock] Listing dev servers");
return {
success: true,
result: {
servers: [],
},
};
},
}; };
} }
@@ -1199,7 +1459,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
runFeature: async ( runFeature: async (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees?: boolean useWorktrees?: boolean,
worktreePath?: string
) => { ) => {
if (mockRunningFeatures.has(featureId)) { if (mockRunningFeatures.has(featureId)) {
return { return {
@@ -1209,7 +1470,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
} }
console.log( console.log(
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}` `[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
); );
mockRunningFeatures.add(featureId); mockRunningFeatures.add(featureId);
simulateAutoModeLoop(projectPath, featureId); simulateAutoModeLoop(projectPath, featureId);
@@ -1231,7 +1492,11 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true, passes: true }; return { success: true, passes: true };
}, },
resumeFeature: async (projectPath: string, featureId: string) => { resumeFeature: async (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) => {
if (mockRunningFeatures.has(featureId)) { if (mockRunningFeatures.has(featureId)) {
return { return {
success: false, success: false,
@@ -1369,7 +1634,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
projectPath: string, projectPath: string,
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[] imagePaths?: string[],
worktreePath?: string
) => { ) => {
if (mockRunningFeatures.has(featureId)) { if (mockRunningFeatures.has(featureId)) {
return { return {
@@ -1394,8 +1660,16 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true }; return { success: true };
}, },
commitFeature: async (projectPath: string, featureId: string) => { commitFeature: async (
console.log("[Mock] Committing feature:", { projectPath, featureId }); projectPath: string,
featureId: string,
worktreePath?: string
) => {
console.log("[Mock] Committing feature:", {
projectPath,
featureId,
worktreePath,
});
// Simulate commit operation // Simulate commit operation
emitAutoModeEvent({ emitAutoModeEvent({

View File

@@ -468,12 +468,24 @@ export class HttpApiClient implements ElectronAPI {
isLinux: boolean; isLinux: boolean;
}> => this.get("/api/setup/platform"), }> => this.get("/api/setup/platform"),
verifyClaudeAuth: (authMethod?: "cli" | "api_key"): Promise<{ verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
): Promise<{
success: boolean; success: boolean;
authenticated: boolean; authenticated: boolean;
error?: string; error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }), }> => this.post("/api/setup/verify-claude-auth", { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
onInstallProgress: (callback: (progress: unknown) => void) => { onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback); return this.subscribeToEvent("agent:stream", callback);
}, },
@@ -515,17 +527,27 @@ export class HttpApiClient implements ElectronAPI {
runFeature: ( runFeature: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees?: boolean useWorktrees?: boolean,
worktreePath?: string
) => ) =>
this.post("/api/auto-mode/run-feature", { this.post("/api/auto-mode/run-feature", {
projectPath, projectPath,
featureId, featureId,
useWorktrees, useWorktrees,
worktreePath,
}), }),
verifyFeature: (projectPath: string, featureId: string) => verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }), this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string) => resumeFeature: (
this.post("/api/auto-mode/resume-feature", { projectPath, featureId }), projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) => contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }), this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
analyzeProject: (projectPath: string) => analyzeProject: (projectPath: string) =>
@@ -534,16 +556,26 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string, projectPath: string,
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[] imagePaths?: string[],
worktreePath?: string
) => ) =>
this.post("/api/auto-mode/follow-up-feature", { this.post("/api/auto-mode/follow-up-feature", {
projectPath, projectPath,
featureId, featureId,
prompt, prompt,
imagePaths, imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
projectPath,
featureId,
worktreePath,
}), }),
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
approvePlan: ( approvePlan: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
@@ -582,8 +614,6 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API // Worktree API
worktree: WorktreeAPI = { worktree: WorktreeAPI = {
revertFeature: (projectPath: string, featureId: string) =>
this.post("/api/worktree/revert", { projectPath, featureId }),
mergeFeature: (projectPath: string, featureId: string, options?: object) => mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }), this.post("/api/worktree/merge", { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) => getInfo: (projectPath: string, featureId: string) =>
@@ -592,6 +622,30 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/worktree/status", { projectPath, featureId }), this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) => list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }), this.post("/api/worktree/list", { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) => getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }), this.post("/api/worktree/diffs", { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) => getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
@@ -600,6 +654,24 @@ export class HttpApiClient implements ElectronAPI {
featureId, featureId,
filePath, filePath,
}), }),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
}; };
// Git API // Git API
@@ -655,7 +727,10 @@ export class HttpApiClient implements ElectronAPI {
maxFeatures, maxFeatures,
}), }),
generateFeatures: (projectPath: string, maxFeatures?: number) => generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }), this.post("/api/spec-regeneration/generate-features", {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"), stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"), status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => { onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
@@ -713,13 +788,15 @@ export class HttpApiClient implements ElectronAPI {
sessionId: string, sessionId: string,
message: string, message: string,
workingDirectory?: string, workingDirectory?: string,
imagePaths?: string[] imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> => ): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", { this.post("/api/agent/send", {
sessionId, sessionId,
message, message,
workingDirectory, workingDirectory,
imagePaths, imagePaths,
model,
}), }),
getHistory: ( getHistory: (

View File

@@ -15,6 +15,38 @@ export type LogEntryType =
| "warning" | "warning"
| "thinking"; | "thinking";
export type ToolCategory = 'read' | 'edit' | 'write' | 'bash' | 'search' | 'todo' | 'task' | 'other';
const TOOL_CATEGORIES: Record<string, ToolCategory> = {
'Read': 'read',
'Edit': 'edit',
'Write': 'write',
'Bash': 'bash',
'Grep': 'search',
'Glob': 'search',
'WebSearch': 'search',
'WebFetch': 'read',
'TodoWrite': 'todo',
'Task': 'task',
'NotebookEdit': 'edit',
'KillShell': 'bash',
};
/**
* Categorizes a tool name into a predefined category
*/
export function categorizeToolName(toolName: string): ToolCategory {
return TOOL_CATEGORIES[toolName] || 'other';
}
export interface LogEntryMetadata {
toolName?: string;
toolCategory?: ToolCategory;
filePath?: string;
summary?: string;
phase?: string;
}
export interface LogEntry { export interface LogEntry {
id: string; id: string;
type: LogEntryType; type: LogEntryType;
@@ -22,11 +54,7 @@ export interface LogEntry {
content: string; content: string;
timestamp?: string; timestamp?: string;
collapsed?: boolean; collapsed?: boolean;
metadata?: { metadata?: LogEntryMetadata;
toolName?: string;
phase?: string;
[key: string]: string | undefined;
};
} }
/** /**
@@ -93,11 +121,16 @@ function detectEntryType(content: string): LogEntryType {
return "error"; return "error";
} }
// Success messages // Success messages and summary sections
if ( if (
trimmed.startsWith("✅") || trimmed.startsWith("✅") ||
trimmed.toLowerCase().includes("success") || trimmed.toLowerCase().includes("success") ||
trimmed.toLowerCase().includes("completed") trimmed.toLowerCase().includes("completed") ||
// Summary tags (preferred format from agent)
trimmed.startsWith("<summary>") ||
// Markdown summary headers (fallback)
trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)
) { ) {
return "success"; return "success";
} }
@@ -107,10 +140,11 @@ function detectEntryType(content: string): LogEntryType {
return "warning"; return "warning";
} }
// Thinking/Preparation info // Thinking/Preparation info (be specific to avoid matching summary content)
if ( if (
trimmed.toLowerCase().includes("ultrathink") || trimmed.toLowerCase().includes("ultrathink") ||
trimmed.toLowerCase().includes("thinking level") || trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
trimmed.match(/^thinking level\s*$/i) ||
trimmed.toLowerCase().includes("estimated cost") || trimmed.toLowerCase().includes("estimated cost") ||
trimmed.toLowerCase().includes("estimated time") || trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") || trimmed.toLowerCase().includes("budget tokens") ||
@@ -135,9 +169,11 @@ function detectEntryType(content: string): LogEntryType {
/** /**
* Extracts tool name from a tool call entry * Extracts tool name from a tool call entry
* Matches both "🔧 Tool: Name" and "Tool: Name" formats
*/ */
function extractToolName(content: string): string | undefined { function extractToolName(content: string): string | undefined {
const match = content.match(/🔧\s*Tool:\s*(\S+)/); // Try emoji format first, then plain format
const match = content.match(/(?:🔧\s*)?Tool:\s*(\S+)/);
return match?.[1]; return match?.[1];
} }
@@ -159,6 +195,134 @@ function extractPhase(content: string): string | undefined {
return match?.[1]?.toLowerCase(); return match?.[1]?.toLowerCase();
} }
/**
* Extracts file path from tool input JSON
*/
function extractFilePath(content: string): string | undefined {
try {
const inputMatch = content.match(/Input:\s*([\s\S]*)/);
if (!inputMatch) return undefined;
const jsonStr = inputMatch[1].trim();
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
if (typeof parsed.file_path === 'string') return parsed.file_path;
if (typeof parsed.path === 'string') return parsed.path;
if (typeof parsed.notebook_path === 'string') return parsed.notebook_path;
return undefined;
} catch {
return undefined;
}
}
/**
* Generates a smart summary for tool calls based on the tool name and input
*/
export function generateToolSummary(toolName: string, content: string): string | undefined {
try {
// Try to parse JSON input
const inputMatch = content.match(/Input:\s*([\s\S]*)/);
if (!inputMatch) return undefined;
const jsonStr = inputMatch[1].trim();
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
switch (toolName) {
case 'Read': {
const filePath = parsed.file_path as string | undefined;
return `Reading ${filePath?.split('/').pop() || 'file'}`;
}
case 'Edit': {
const filePath = parsed.file_path as string | undefined;
const fileName = filePath?.split('/').pop() || 'file';
return `Editing ${fileName}`;
}
case 'Write': {
const filePath = parsed.file_path as string | undefined;
return `Writing ${filePath?.split('/').pop() || 'file'}`;
}
case 'Bash': {
const command = parsed.command as string | undefined;
const cmd = command?.slice(0, 50) || '';
return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`;
}
case 'Grep': {
const pattern = parsed.pattern as string | undefined;
return `Searching for "${pattern?.slice(0, 30) || ''}"`;
}
case 'Glob': {
const pattern = parsed.pattern as string | undefined;
return `Finding files: ${pattern || ''}`;
}
case 'TodoWrite': {
const todos = parsed.todos as unknown[] | undefined;
const todoCount = todos?.length || 0;
return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`;
}
case 'Task': {
const subagentType = parsed.subagent_type as string | undefined;
const description = parsed.description as string | undefined;
return `${subagentType || 'Agent'}: ${description || ''}`;
}
case 'WebSearch': {
const query = parsed.query as string | undefined;
return `Searching: "${query?.slice(0, 40) || ''}"`;
}
case 'WebFetch': {
const url = parsed.url as string | undefined;
return `Fetching: ${url?.slice(0, 40) || ''}`;
}
case 'NotebookEdit': {
const notebookPath = parsed.notebook_path as string | undefined;
return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`;
}
case 'KillShell': {
return 'Terminating shell session';
}
default:
return undefined;
}
} catch {
return undefined;
}
}
/**
* Determines if an entry should be collapsed by default
*/
export function shouldCollapseByDefault(entry: LogEntry): boolean {
// Collapse if content is long
if (entry.content.length > 200) return true;
// Collapse if contains multi-line JSON (> 5 lines)
const lineCount = entry.content.split('\n').length;
if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) {
return true;
}
// Collapse TodoWrite with multiple items
if (entry.metadata?.toolName === 'TodoWrite') {
try {
const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/);
if (inputMatch) {
const parsed = JSON.parse(inputMatch[1].trim()) as Record<string, unknown>;
const todos = parsed.todos as unknown[] | undefined;
if (todos && todos.length > 1) return true;
}
} catch {
// Ignore parse errors
}
}
// Collapse Edit with code blocks
if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) {
return true;
}
return false;
}
/** /**
* Generates a title for a log entry * Generates a title for a log entry
*/ */
@@ -183,8 +347,19 @@ function generateTitle(type: LogEntryType, content: string): string {
} }
case "error": case "error":
return "Error"; return "Error";
case "success": case "success": {
// Check if it's a summary section
if (content.startsWith("<summary>") || content.includes("<summary>")) {
return "Summary";
}
if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) {
return "Summary";
}
if (content.match(/^All tasks completed/i) || content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)) {
return "Summary";
}
return "Success"; return "Success";
}
case "warning": case "warning":
return "Warning"; return "Warning";
case "thinking": case "thinking":
@@ -198,6 +373,39 @@ function generateTitle(type: LogEntryType, content: string): string {
} }
} }
/**
* Tracks bracket depth for JSON accumulation
*/
function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } {
let braceChange = 0;
let bracketChange = 0;
let inString = false;
let escapeNext = false;
for (const char of line) {
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') braceChange++;
else if (char === '}') braceChange--;
else if (char === '[') bracketChange++;
else if (char === ']') bracketChange--;
}
return { braceChange, bracketChange };
}
/** /**
* Parses raw log output into structured entries * Parses raw log output into structured entries
*/ */
@@ -213,10 +421,33 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
let currentContent: string[] = []; let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation let entryStartLine = 0; // Track the starting line for deterministic ID generation
// JSON accumulation state
let inJsonAccumulation = false;
let jsonBraceDepth = 0;
let jsonBracketDepth = 0;
// Summary tag accumulation state
let inSummaryAccumulation = false;
const finalizeEntry = () => { const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) { if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim(); currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) { if (currentEntry.content) {
// Populate enhanced metadata for tool calls
const toolName = currentEntry.metadata?.toolName;
if (toolName && currentEntry.type === 'tool_call') {
const toolCategory = categorizeToolName(toolName);
const filePath = extractFilePath(currentEntry.content);
const summary = generateToolSummary(toolName, currentEntry.content);
currentEntry.metadata = {
...currentEntry.metadata,
toolCategory,
filePath,
summary,
};
}
// Generate deterministic ID based on content and position // Generate deterministic ID based on content and position
const entryWithId: LogEntry = { const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>, ...currentEntry as Omit<LogEntry, 'id'>,
@@ -226,6 +457,10 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
} }
} }
currentContent = []; currentContent = [];
inJsonAccumulation = false;
jsonBraceDepth = 0;
jsonBracketDepth = 0;
inSummaryAccumulation = false;
}; };
let lineIndex = 0; let lineIndex = 0;
@@ -238,6 +473,35 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
continue; continue;
} }
// If we're in JSON accumulation mode, keep accumulating until depth returns to 0
if (inJsonAccumulation) {
currentContent.push(line);
const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
jsonBraceDepth += braceChange;
jsonBracketDepth += bracketChange;
// JSON is complete when depth returns to 0
if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) {
inJsonAccumulation = false;
jsonBraceDepth = 0;
jsonBracketDepth = 0;
}
lineIndex++;
continue;
}
// If we're in summary accumulation mode, keep accumulating until </summary>
if (inSummaryAccumulation) {
currentContent.push(line);
// Summary is complete when we see closing tag
if (trimmedLine.includes("</summary>")) {
inSummaryAccumulation = false;
// Don't finalize here - let normal flow handle it
}
lineIndex++;
continue;
}
// Detect if this line starts a new entry // Detect if this line starts a new entry
const lineType = detectEntryType(trimmedLine); const lineType = detectEntryType(trimmedLine);
const isNewEntry = const isNewEntry =
@@ -256,8 +520,17 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
trimmedLine.match(/\[ERROR\]/i) || trimmedLine.match(/\[ERROR\]/i) ||
trimmedLine.match(/\[Status\]/i) || trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes("ultrathink preparation") || trimmedLine.toLowerCase().includes("ultrathink preparation") ||
trimmedLine.toLowerCase().includes("thinking level") || trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call"); // Summary tags (preferred format from agent)
trimmedLine.startsWith("<summary>") ||
// Agent summary sections (markdown headers - fallback)
trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
// Summary introduction lines
trimmedLine.match(/^All tasks completed/i) ||
trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i);
// Check if this is an Input: line that should trigger JSON accumulation
const isInputLine = trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call";
if (isNewEntry) { if (isNewEntry) {
// Finalize previous entry // Finalize previous entry
@@ -277,9 +550,45 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
}, },
}; };
currentContent.push(trimmedLine); currentContent.push(trimmedLine);
// If this is a <summary> tag, start summary accumulation mode
if (trimmedLine.startsWith("<summary>") && !trimmedLine.includes("</summary>")) {
inSummaryAccumulation = true;
}
} else if (isInputLine && currentEntry) {
// Start JSON accumulation mode
currentContent.push(trimmedLine);
// Check if there's JSON on the same line after "Input:"
const inputContent = trimmedLine.replace(/^Input:\s*/, '');
if (inputContent) {
const { braceChange, bracketChange } = calculateBracketDepth(inputContent);
jsonBraceDepth = braceChange;
jsonBracketDepth = bracketChange;
// Only enter accumulation mode if JSON is incomplete
if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
inJsonAccumulation = true;
}
} else {
// Input: line with JSON starting on next line
inJsonAccumulation = true;
}
} else if (currentEntry) { } else if (currentEntry) {
// Continue current entry // Continue current entry
currentContent.push(line); currentContent.push(line);
// Check if this line starts a JSON block
if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) {
const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine);
if (braceChange > 0 || bracketChange > 0) {
jsonBraceDepth = braceChange;
jsonBracketDepth = bracketChange;
if (jsonBraceDepth > 0 || jsonBracketDepth > 0) {
inJsonAccumulation = true;
}
}
}
} else { } else {
// Track starting line for deterministic ID // Track starting line for deterministic ID
entryStartLine = lineIndex; entryStartLine = lineIndex;

View File

@@ -48,6 +48,31 @@ export async function initializeProject(
const existingFiles: string[] = []; const existingFiles: string[] = [];
try { try {
// Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) {
console.log("[project-init] Initializing git repository...");
try {
// Initialize git and create an initial empty commit via server route
const result = await api.worktree?.initGit(projectPath);
if (result?.success && result.result?.initialized) {
createdFiles.push(".git");
console.log("[project-init] Git repository initialized with initial commit");
} else if (result?.success && !result.result?.initialized) {
// Git already existed (shouldn't happen since we checked, but handle it)
existingFiles.push(".git");
console.log("[project-init] Git repository already exists");
} else {
console.warn("[project-init] Failed to initialize git repository:", result?.error);
}
} catch (gitError) {
console.warn("[project-init] Failed to initialize git repository:", gitError);
// Don't fail the whole initialization if git init fails
}
} else {
existingFiles.push(".git");
}
// Create all required directories // Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) { for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`; const fullPath = `${projectPath}/${dir}`;

View File

@@ -35,3 +35,20 @@ export function truncateDescription(description: string, maxLength = 50): string
} }
return `${description.slice(0, maxLength)}...`; return `${description.slice(0, maxLength)}...`;
} }
/**
* Normalize a file path to use forward slashes consistently.
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
}
/**
* Compare two paths for equality, handling cross-platform differences.
* Normalizes both paths to forward slashes before comparison.
*/
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}

View File

@@ -30,7 +30,10 @@ export type ThemeMode =
| "catppuccin" | "catppuccin"
| "onedark" | "onedark"
| "synthwave" | "synthwave"
| "red"; | "red"
| "cream"
| "sunset"
| "gray";
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed"; export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
@@ -423,10 +426,25 @@ export interface AppState {
// Feature Default Settings // Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
// Worktree Settings // Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false) useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
worktreesByProject: Record<
string,
Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
>;
// AI Profiles // AI Profiles
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
@@ -607,9 +625,29 @@ export interface AppActions {
// Feature Default Settings actions // Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void; setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
// Worktree Settings actions // Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void; setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
// Profile Display Settings actions // Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void; setShowProfilesOnly: (enabled: boolean) => void;
@@ -769,7 +807,10 @@ const initialState: AppState = {
maxConcurrency: 3, // Default to 3 concurrent agents maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: "standard", // Default to standard detail level kanbanCardDetailLevel: "standard", // Default to standard detail level
defaultSkipTests: true, // Default to manual verification (tests disabled) defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
useWorktrees: false, // Default to disabled (worktree feature is experimental) useWorktrees: false, // Default to disabled (worktree feature is experimental)
currentWorktreeByProject: {},
worktreesByProject: {},
showProfilesOnly: false, // Default to showing all options (not profiles only) showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted) muteDoneSound: false, // Default to sound enabled (not muted)
@@ -1361,10 +1402,39 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions // Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions // Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
setCurrentWorktree: (projectPath, worktreePath, branch) => {
const current = get().currentWorktreeByProject;
set({
currentWorktreeByProject: {
...current,
[projectPath]: { path: worktreePath, branch },
},
});
},
setWorktrees: (projectPath, worktrees) => {
const current = get().worktreesByProject;
set({
worktreesByProject: {
...current,
[projectPath]: worktrees,
},
});
},
getCurrentWorktree: (projectPath) => {
return get().currentWorktreeByProject[projectPath] ?? null;
},
getWorktrees: (projectPath) => {
return get().worktreesByProject[projectPath] ?? [];
},
// Profile Display Settings actions // Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }), setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2230,7 +2300,10 @@ export const useAppStore = create<AppState & AppActions>()(
maxConcurrency: state.maxConcurrency, maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject, autoModeByProject: state.autoModeByProject,
defaultSkipTests: state.defaultSkipTests, defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees, useWorktrees: state.useWorktrees,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreesByProject: state.worktreesByProject,
showProfilesOnly: state.showProfilesOnly, showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts, keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound, muteDoneSound: state.muteDoneSound,

View File

@@ -10,6 +10,16 @@ export interface CliStatus {
error?: string; error?: string;
} }
// GitHub CLI Status
export interface GhCliStatus {
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}
// Claude Auth Method - all possible authentication sources // Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod = export type ClaudeAuthMethod =
| "oauth_token_env" | "oauth_token_env"
@@ -45,6 +55,7 @@ export type SetupStep =
| "welcome" | "welcome"
| "claude_detect" | "claude_detect"
| "claude_auth" | "claude_auth"
| "github"
| "complete"; | "complete";
export interface SetupState { export interface SetupState {
@@ -58,6 +69,9 @@ export interface SetupState {
claudeAuthStatus: ClaudeAuthStatus | null; claudeAuthStatus: ClaudeAuthStatus | null;
claudeInstallProgress: InstallProgress; claudeInstallProgress: InstallProgress;
// GitHub CLI state
ghCliStatus: GhCliStatus | null;
// Setup preferences // Setup preferences
skipClaudeSetup: boolean; skipClaudeSetup: boolean;
} }
@@ -76,6 +90,9 @@ export interface SetupActions {
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void; setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
resetClaudeInstallProgress: () => void; resetClaudeInstallProgress: () => void;
// GitHub CLI
setGhCliStatus: (status: GhCliStatus | null) => void;
// Preferences // Preferences
setSkipClaudeSetup: (skip: boolean) => void; setSkipClaudeSetup: (skip: boolean) => void;
} }
@@ -99,6 +116,8 @@ const initialState: SetupState = {
claudeAuthStatus: null, claudeAuthStatus: null,
claudeInstallProgress: { ...initialInstallProgress }, claudeInstallProgress: { ...initialInstallProgress },
ghCliStatus: null,
skipClaudeSetup: shouldSkipSetup, skipClaudeSetup: shouldSkipSetup,
}; };
@@ -145,6 +164,9 @@ export const useSetupStore = create<SetupState & SetupActions>()(
claudeInstallProgress: { ...initialInstallProgress }, claudeInstallProgress: { ...initialInstallProgress },
}), }),
// GitHub CLI
setGhCliStatus: (status) => set({ ghCliStatus: status }),
// Preferences // Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}), }),

View File

@@ -84,7 +84,8 @@ export interface AgentAPI {
sessionId: string, sessionId: string,
message: string, message: string,
workingDirectory?: string, workingDirectory?: string,
imagePaths?: string[] imagePaths?: string[],
model?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
@@ -350,7 +351,10 @@ export interface SpecRegenerationAPI {
error?: string; error?: string;
}>; }>;
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{ generateFeatures: (
projectPath: string,
maxFeatures?: number
) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
@@ -404,7 +408,8 @@ export interface AutoModeAPI {
runFeature: ( runFeature: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees?: boolean useWorktrees?: boolean,
worktreePath?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
passes?: boolean; passes?: boolean;
@@ -422,7 +427,8 @@ export interface AutoModeAPI {
resumeFeature: ( resumeFeature: (
projectPath: string, projectPath: string,
featureId: string featureId: string,
useWorktrees?: boolean
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
passes?: boolean; passes?: boolean;
@@ -448,7 +454,8 @@ export interface AutoModeAPI {
projectPath: string, projectPath: string,
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[] imagePaths?: string[],
worktreePath?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
passes?: boolean; passes?: boolean;
@@ -646,16 +653,6 @@ export interface FileDiffResult {
} }
export interface WorktreeAPI { export interface WorktreeAPI {
// Revert feature changes by removing the worktree
revertFeature: (
projectPath: string,
featureId: string
) => Promise<{
success: boolean;
removedPath?: string;
error?: string;
}>;
// Merge feature worktree changes back to main branch // Merge feature worktree changes back to main branch
mergeFeature: ( mergeFeature: (
projectPath: string, projectPath: string,
@@ -696,6 +693,108 @@ export interface WorktreeAPI {
error?: string; error?: string;
}>; }>;
// List all worktrees with details (for worktree selector)
listAll: (
projectPath: string,
includeDetails?: boolean
) => Promise<{
success: boolean;
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}>;
error?: string;
}>;
// Create a new worktree
create: (
projectPath: string,
branchName: string,
baseBranch?: string
) => Promise<{
success: boolean;
worktree?: {
path: string;
branch: string;
isNew: boolean;
};
error?: string;
}>;
// Delete a worktree
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) => Promise<{
success: boolean;
deleted?: {
worktreePath: string;
branch: string | null;
};
error?: string;
}>;
// Commit changes in a worktree
commit: (
worktreePath: string,
message: string
) => Promise<{
success: boolean;
result?: {
committed: boolean;
commitHash?: string;
branch?: string;
message?: string;
};
error?: string;
}>;
// Push a worktree branch to remote
push: (
worktreePath: string,
force?: boolean
) => Promise<{
success: boolean;
result?: {
branch: string;
pushed: boolean;
message: string;
};
error?: string;
}>;
// Create a pull request from a worktree
createPR: (
worktreePath: string,
options?: {
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
}
) => Promise<{
success: boolean;
result?: {
branch: string;
committed: boolean;
commitHash?: string;
pushed: boolean;
prUrl?: string;
prCreated: boolean;
prError?: string;
browserUrl?: string;
ghCliAvailable?: boolean;
};
error?: string;
}>;
// Get file diffs for a feature worktree // Get file diffs for a feature worktree
getDiffs: ( getDiffs: (
projectPath: string, projectPath: string,
@@ -708,6 +807,129 @@ export interface WorktreeAPI {
featureId: string, featureId: string,
filePath: string filePath: string
) => Promise<FileDiffResult>; ) => Promise<FileDiffResult>;
// Pull latest changes from remote
pull: (worktreePath: string) => Promise<{
success: boolean;
result?: {
branch: string;
pulled: boolean;
message: string;
};
error?: string;
}>;
// Create and checkout a new branch
checkoutBranch: (
worktreePath: string,
branchName: string
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
newBranch: string;
message: string;
};
error?: string;
}>;
// List all local branches
listBranches: (worktreePath: string) => Promise<{
success: boolean;
result?: {
currentBranch: string;
branches: Array<{
name: string;
isCurrent: boolean;
isRemote: boolean;
}>;
aheadCount: number;
behindCount: number;
};
error?: string;
}>;
// Switch to an existing branch
switchBranch: (
worktreePath: string,
branchName: string
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
currentBranch: string;
message: string;
};
error?: string;
}>;
// Open a worktree directory in the editor
openInEditor: (worktreePath: string) => Promise<{
success: boolean;
result?: {
message: string;
editorName?: string;
};
error?: string;
}>;
// Get the default code editor name
getDefaultEditor: () => Promise<{
success: boolean;
result?: {
editorName: string;
editorCommand: string;
};
error?: string;
}>;
// Initialize git repository in a project
initGit: (projectPath: string) => Promise<{
success: boolean;
result?: {
initialized: boolean;
message: string;
};
error?: string;
}>;
// Start a dev server for a worktree
startDevServer: (
projectPath: string,
worktreePath: string
) => Promise<{
success: boolean;
result?: {
worktreePath: string;
port: number;
url: string;
message: string;
};
error?: string;
}>;
// Stop a dev server for a worktree
stopDevServer: (worktreePath: string) => Promise<{
success: boolean;
result?: {
worktreePath: string;
message: string;
};
error?: string;
}>;
// List all running dev servers
listDevServers: () => Promise<{
success: boolean;
result?: {
servers: Array<{
worktreePath: string;
port: number;
url: string;
}>;
};
error?: string;
}>;
} }
export interface GitAPI { export interface GitAPI {

View File

@@ -0,0 +1,536 @@
/**
* Feature Lifecycle End-to-End Tests
*
* Tests the complete feature lifecycle flow:
* 1. Create a feature in backlog
* 2. Drag to in_progress and wait for agent to finish
* 3. Verify it moves to waiting_approval (manual review)
* 4. Click commit and verify git status shows committed changes
* 5. Drag to verified column
* 6. Archive (complete) the feature
* 7. Open archive modal and restore the feature
* 8. Delete the feature
*
* NOTE: This test uses AUTOMAKER_MOCK_AGENT=true to mock the agent
* so it doesn't make real API calls during CI/CD runs.
*/
import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import {
waitForNetworkIdle,
createTestGitRepo,
cleanupTempDir,
createTempDirPath,
setupProjectWithPathNoWorktrees,
waitForBoardView,
clickAddFeature,
fillAddFeatureDialog,
confirmAddFeature,
dragAndDropWithDndKit,
} from "./utils";
const execAsync = promisify(exec);
// Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
interface TestRepo {
path: string;
cleanup: () => Promise<void>;
}
// Configure all tests to run serially
test.describe.configure({ mode: "serial" });
test.describe("Feature Lifecycle Tests", () => {
let testRepo: TestRepo;
let featureId: string;
test.beforeAll(async () => {
// Create test temp directory
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
});
test.beforeEach(async () => {
// Create a fresh test repo for each test
testRepo = await createTestGitRepo(TEST_TEMP_DIR);
});
test.afterEach(async () => {
// Cleanup test repo after each test
if (testRepo) {
await testRepo.cleanup();
}
});
test.afterAll(async () => {
// Cleanup temp directory
cleanupTempDir(TEST_TEMP_DIR);
});
// this one fails in github actions for some reason
test.skip("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({
page,
}) => {
// Increase timeout for this comprehensive test
test.setTimeout(120000);
// ==========================================================================
// Step 1: Setup and create a feature in backlog
// ==========================================================================
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Wait a bit for the UI to fully load
await page.waitForTimeout(1000);
// Click add feature button
await clickAddFeature(page);
// Fill in the feature details - requesting a file with "yellow" content
const featureDescription =
"Create a file named yellow.txt that contains the text yellow";
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
await descriptionInput.fill(featureDescription);
// Confirm the feature creation
await confirmAddFeature(page);
// Debug: Check the filesystem to see if feature was created
const featuresDir = path.join(testRepo.path, ".automaker", "features");
// Wait for the feature to be created in the filesystem
await expect(async () => {
const dirs = fs.readdirSync(featuresDir);
expect(dirs.length).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
// Reload to force features to load from filesystem
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Wait for the feature card to appear on the board
const featureCard = page.getByText(featureDescription).first();
await expect(featureCard).toBeVisible({ timeout: 15000 });
// Get the feature ID from the filesystem
const featureDirs = fs.readdirSync(featuresDir);
featureId = featureDirs[0];
// Now get the actual card element by testid
const featureCardByTestId = page.locator(
`[data-testid="kanban-card-${featureId}"]`
);
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 2: Drag feature to in_progress and wait for agent to finish
// ==========================================================================
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const inProgressColumn = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
// Perform the drag and drop using dnd-kit compatible method
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
// First verify that the drag succeeded by checking for in_progress status
// This helps diagnose if the drag-drop is working or not
await expect(async () => {
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
);
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
}).toPass({ timeout: 15000 });
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
// Wait for the feature to move to waiting_approval (manual review)
// The status changes are: in_progress -> waiting_approval after agent completes
await expect(async () => {
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
);
expect(featureData.status).toBe("waiting_approval");
}).toPass({ timeout: 30000 });
// Refresh page to ensure UI reflects the status change
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
// ==========================================================================
// Step 3: Verify feature is in waiting_approval (manual review) column
// ==========================================================================
const waitingApprovalColumn = page.locator(
'[data-testid="kanban-column-waiting_approval"]'
);
const cardInWaitingApproval = waitingApprovalColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
// Verify the mock agent created the yellow.txt file
const yellowFilePath = path.join(testRepo.path, "yellow.txt");
expect(fs.existsSync(yellowFilePath)).toBe(true);
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
expect(yellowContent).toBe("yellow");
// ==========================================================================
// Step 4: Click commit and verify git status shows committed changes
// ==========================================================================
// The commit button should be visible on the card in waiting_approval
const commitButton = page.locator(`[data-testid="commit-${featureId}"]`);
await expect(commitButton).toBeVisible({ timeout: 5000 });
await commitButton.click();
// Wait for the commit to process
await page.waitForTimeout(2000);
// Verify git status shows clean (changes committed)
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
cwd: testRepo.path,
});
// After commit, the yellow.txt file should be committed, so git status should be clean
// (only .automaker directory might have changes)
expect(gitStatus.includes("yellow.txt")).toBe(false);
// Verify the commit exists in git log
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
cwd: testRepo.path,
});
expect(gitLog.toLowerCase()).toContain("yellow");
// ==========================================================================
// Step 5: Verify feature moved to verified column after commit
// ==========================================================================
// Feature should automatically move to verified after commit
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
const verifiedColumn = page.locator(
'[data-testid="kanban-column-verified"]'
);
const cardInVerified = verifiedColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 6: Archive (complete) the feature
// ==========================================================================
// Click the Complete button on the verified card
const completeButton = page.locator(
`[data-testid="complete-${featureId}"]`
);
await expect(completeButton).toBeVisible({ timeout: 5000 });
await completeButton.click();
// Wait for the archive action to complete
await page.waitForTimeout(1000);
// Verify the feature is no longer visible on the board (it's archived)
await expect(cardInVerified).not.toBeVisible({ timeout: 5000 });
// Verify feature status is completed in filesystem
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
);
expect(featureData.status).toBe("completed");
// ==========================================================================
// Step 7: Open archive modal and restore the feature
// ==========================================================================
// Click the completed features button to open the archive modal
const completedFeaturesButton = page.locator(
'[data-testid="completed-features-button"]'
);
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
await completedFeaturesButton.click();
// Wait for the modal to open
const completedModal = page.locator(
'[data-testid="completed-features-modal"]'
);
await expect(completedModal).toBeVisible({ timeout: 5000 });
// Verify the archived feature is shown in the modal
const archivedCard = completedModal.locator(
`[data-testid="completed-card-${featureId}"]`
);
await expect(archivedCard).toBeVisible({ timeout: 5000 });
// Click the restore button
const restoreButton = page.locator(
`[data-testid="unarchive-${featureId}"]`
);
await expect(restoreButton).toBeVisible({ timeout: 5000 });
await restoreButton.click();
// Wait for the restore action to complete
await page.waitForTimeout(1000);
// Close the modal - use first() to select the footer Close button, not the X button
const closeButton = completedModal
.locator('button:has-text("Close")')
.first();
await closeButton.click();
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
// Verify the feature is back in the verified column
const restoredCard = verifiedColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
await expect(restoredCard).toBeVisible({ timeout: 10000 });
// Verify feature status is verified in filesystem
const restoredFeatureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
);
expect(restoredFeatureData.status).toBe("verified");
// ==========================================================================
// Step 8: Delete the feature and verify it's removed
// ==========================================================================
// Click the delete button on the verified card
const deleteButton = page.locator(
`[data-testid="delete-verified-${featureId}"]`
);
await expect(deleteButton).toBeVisible({ timeout: 5000 });
await deleteButton.click();
// Wait for the confirmation dialog
const confirmDialog = page.locator(
'[data-testid="delete-confirmation-dialog"]'
);
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
// Click the confirm delete button
const confirmDeleteButton = page.locator(
'[data-testid="confirm-delete-button"]'
);
await confirmDeleteButton.click();
// Wait for the delete action to complete
await page.waitForTimeout(1000);
// Verify the feature is no longer visible on the board
await expect(restoredCard).not.toBeVisible({ timeout: 5000 });
// Verify the feature directory is deleted from filesystem
const featureDirExists = fs.existsSync(path.join(featuresDir, featureId));
expect(featureDirExists).toBe(false);
});
// this one fails in github actions for some reason
test.skip("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({
page,
}) => {
// This test verifies that stopping a feature and restarting it works correctly
// Bug: Previously, stopping a feature and immediately restarting could cause
// "Feature not found" error due to race conditions
test.setTimeout(120000);
// ==========================================================================
// Step 1: Setup and create a feature in backlog
// ==========================================================================
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
// Click add feature button
await clickAddFeature(page);
// Fill in the feature details
const featureDescription = "Create a file named test-restart.txt";
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
await descriptionInput.fill(featureDescription);
// Confirm the feature creation
await confirmAddFeature(page);
// Wait for the feature to be created in the filesystem
const featuresDir = path.join(testRepo.path, ".automaker", "features");
await expect(async () => {
const dirs = fs.readdirSync(featuresDir);
expect(dirs.length).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
// Get the feature ID
const featureDirs = fs.readdirSync(featuresDir);
const testFeatureId = featureDirs[0];
// Reload to ensure features are loaded
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Wait for the feature card to appear
const featureCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
await expect(featureCard).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 2: Drag feature to in_progress (first start)
// ==========================================================================
const dragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
const inProgressColumn = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
// Verify feature file still exists and is readable
const featureFilePath = path.join(
featuresDir,
testFeatureId,
"feature.json"
);
expect(fs.existsSync(featureFilePath)).toBe(true);
// First verify that the drag succeeded by checking for in_progress status
await expect(async () => {
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
}).toPass({ timeout: 15000 });
// ==========================================================================
// Step 3: Wait for the mock agent to complete (it's fast in mock mode)
// ==========================================================================
// The mock agent completes quickly, so we wait for it to finish
await expect(async () => {
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.status).toBe("waiting_approval");
}).toPass({ timeout: 30000 });
// Verify feature file still exists after completion
expect(fs.existsSync(featureFilePath)).toBe(true);
const featureDataAfterComplete = JSON.parse(
fs.readFileSync(featureFilePath, "utf-8")
);
console.log(
"Feature status after first run:",
featureDataAfterComplete.status
);
// Reload to ensure clean state
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
// ==========================================================================
// Step 4: Move feature back to backlog to simulate stop scenario
// ==========================================================================
// Feature is in waiting_approval, drag it back to backlog
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
const currentCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
const currentDragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
await expect(currentCard).toBeVisible({ timeout: 10000 });
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
await page.waitForTimeout(500);
// Verify feature is in backlog
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(data.status).toBe("backlog");
}).toPass({ timeout: 10000 });
// Reload to ensure clean state
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
// ==========================================================================
// Step 5: Restart the feature (drag to in_progress again)
// ==========================================================================
const restartCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
await expect(restartCard).toBeVisible({ timeout: 10000 });
const restartDragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
const inProgressColumnRestart = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
// Listen for console errors to catch "Feature not found"
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text());
}
});
// Drag to in_progress to restart
await dragAndDropWithDndKit(
page,
restartDragHandle,
inProgressColumnRestart
);
// Verify the feature file still exists
expect(fs.existsSync(featureFilePath)).toBe(true);
// First verify that the restart drag succeeded by checking for in_progress status
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(data.status);
}).toPass({ timeout: 15000 });
// Verify no "Feature not found" errors in console
const featureNotFoundErrors = consoleErrors.filter(
(err) => err.includes("not found") || err.includes("Feature")
);
expect(featureNotFoundErrors).toEqual([]);
// Wait for the mock agent to complete and move to waiting_approval
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(data.status).toBe("waiting_approval");
}).toPass({ timeout: 30000 });
console.log("Feature successfully restarted after stop!");
});
});

View File

@@ -192,7 +192,8 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
resetFixtureSpec(); resetFixtureSpec();
}); });
test("should open project via file browser, edit spec, and persist", async ({ // Skip in CI - file browser navigation is flaky in headless environments
test.skip("should open project via file browser, edit spec, and persist", async ({
page, page,
}) => { }) => {
// Navigate to app first // Navigate to app first

View File

@@ -0,0 +1,272 @@
/**
* API client utilities for making API calls in tests
* Provides type-safe wrappers around common API operations
*/
import { Page, APIResponse } from "@playwright/test";
import { API_ENDPOINTS } from "../core/constants";
// ============================================================================
// Types
// ============================================================================
export interface WorktreeInfo {
path: string;
branch: string;
isNew?: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export interface WorktreeListResponse {
success: boolean;
worktrees: WorktreeInfo[];
error?: string;
}
export interface WorktreeCreateResponse {
success: boolean;
worktree?: WorktreeInfo;
error?: string;
}
export interface WorktreeDeleteResponse {
success: boolean;
error?: string;
}
export interface CommitResult {
committed: boolean;
branch?: string;
commitHash?: string;
message?: string;
}
export interface CommitResponse {
success: boolean;
result?: CommitResult;
error?: string;
}
export interface SwitchBranchResult {
previousBranch: string;
currentBranch: string;
message: string;
}
export interface SwitchBranchResponse {
success: boolean;
result?: SwitchBranchResult;
error?: string;
code?: string;
}
export interface BranchInfo {
name: string;
isCurrent: boolean;
}
export interface ListBranchesResult {
currentBranch: string;
branches: BranchInfo[];
}
export interface ListBranchesResponse {
success: boolean;
result?: ListBranchesResult;
error?: string;
}
// ============================================================================
// Worktree API Client
// ============================================================================
export class WorktreeApiClient {
constructor(private page: Page) {}
/**
* Create a new worktree
*/
async create(
projectPath: string,
branchName: string,
baseBranch?: string
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.create, {
data: {
projectPath,
branchName,
baseBranch,
},
});
const data = await response.json();
return { response, data };
}
/**
* Delete a worktree
*/
async delete(
projectPath: string,
worktreePath: string,
deleteBranch: boolean = true
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.delete, {
data: {
projectPath,
worktreePath,
deleteBranch,
},
});
const data = await response.json();
return { response, data };
}
/**
* List all worktrees
*/
async list(
projectPath: string,
includeDetails: boolean = true
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.list, {
data: {
projectPath,
includeDetails,
},
});
const data = await response.json();
return { response, data };
}
/**
* Commit changes in a worktree
*/
async commit(
worktreePath: string,
message: string
): Promise<{ response: APIResponse; data: CommitResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.commit, {
data: {
worktreePath,
message,
},
});
const data = await response.json();
return { response, data };
}
/**
* Switch branches in a worktree
*/
async switchBranch(
worktreePath: string,
branchName: string
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.switchBranch, {
data: {
worktreePath,
branchName,
},
});
const data = await response.json();
return { response, data };
}
/**
* List all branches
*/
async listBranches(
worktreePath: string
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
const response = await this.page.request.post(API_ENDPOINTS.worktree.listBranches, {
data: {
worktreePath,
},
});
const data = await response.json();
return { response, data };
}
}
// ============================================================================
// Factory Functions
// ============================================================================
/**
* Create a WorktreeApiClient instance
*/
export function createWorktreeApiClient(page: Page): WorktreeApiClient {
return new WorktreeApiClient(page);
}
// ============================================================================
// Convenience Functions (for direct use without creating a client)
// ============================================================================
/**
* Create a worktree via API
*/
export async function apiCreateWorktree(
page: Page,
projectPath: string,
branchName: string,
baseBranch?: string
): Promise<{ response: APIResponse; data: WorktreeCreateResponse }> {
return new WorktreeApiClient(page).create(projectPath, branchName, baseBranch);
}
/**
* Delete a worktree via API
*/
export async function apiDeleteWorktree(
page: Page,
projectPath: string,
worktreePath: string,
deleteBranch: boolean = true
): Promise<{ response: APIResponse; data: WorktreeDeleteResponse }> {
return new WorktreeApiClient(page).delete(projectPath, worktreePath, deleteBranch);
}
/**
* List worktrees via API
*/
export async function apiListWorktrees(
page: Page,
projectPath: string,
includeDetails: boolean = true
): Promise<{ response: APIResponse; data: WorktreeListResponse }> {
return new WorktreeApiClient(page).list(projectPath, includeDetails);
}
/**
* Commit changes in a worktree via API
*/
export async function apiCommitWorktree(
page: Page,
worktreePath: string,
message: string
): Promise<{ response: APIResponse; data: CommitResponse }> {
return new WorktreeApiClient(page).commit(worktreePath, message);
}
/**
* Switch branches in a worktree via API
*/
export async function apiSwitchBranch(
page: Page,
worktreePath: string,
branchName: string
): Promise<{ response: APIResponse; data: SwitchBranchResponse }> {
return new WorktreeApiClient(page).switchBranch(worktreePath, branchName);
}
/**
* List branches via API
*/
export async function apiListBranches(
page: Page,
worktreePath: string
): Promise<{ response: APIResponse; data: ListBranchesResponse }> {
return new WorktreeApiClient(page).listBranches(worktreePath);
}

View File

@@ -0,0 +1,187 @@
/**
* Centralized constants for test utilities
* This file contains all shared constants like URLs, timeouts, and selectors
*/
// ============================================================================
// API Configuration
// ============================================================================
/**
* Base URL for the API server
*/
export const API_BASE_URL = "http://localhost:3008";
/**
* API endpoints for worktree operations
*/
export const API_ENDPOINTS = {
worktree: {
create: `${API_BASE_URL}/api/worktree/create`,
delete: `${API_BASE_URL}/api/worktree/delete`,
list: `${API_BASE_URL}/api/worktree/list`,
commit: `${API_BASE_URL}/api/worktree/commit`,
switchBranch: `${API_BASE_URL}/api/worktree/switch-branch`,
listBranches: `${API_BASE_URL}/api/worktree/list-branches`,
status: `${API_BASE_URL}/api/worktree/status`,
info: `${API_BASE_URL}/api/worktree/info`,
},
fs: {
browse: `${API_BASE_URL}/api/fs/browse`,
read: `${API_BASE_URL}/api/fs/read`,
write: `${API_BASE_URL}/api/fs/write`,
},
features: {
list: `${API_BASE_URL}/api/features/list`,
create: `${API_BASE_URL}/api/features/create`,
update: `${API_BASE_URL}/api/features/update`,
delete: `${API_BASE_URL}/api/features/delete`,
},
} as const;
// ============================================================================
// Timeout Configuration
// ============================================================================
/**
* Default timeouts in milliseconds
*/
export const TIMEOUTS = {
/** Default timeout for element visibility checks */
default: 5000,
/** Short timeout for quick checks */
short: 2000,
/** Medium timeout for standard operations */
medium: 10000,
/** Long timeout for slow operations */
long: 30000,
/** Extra long timeout for very slow operations */
extraLong: 60000,
/** Timeout for animations to complete */
animation: 300,
/** Small delay for UI to settle */
settle: 500,
/** Delay for network operations */
network: 1000,
} as const;
// ============================================================================
// Test ID Selectors
// ============================================================================
/**
* Common data-testid selectors organized by component/view
*/
export const TEST_IDS = {
// Sidebar & Navigation
sidebar: "sidebar",
navBoard: "nav-board",
navSpec: "nav-spec",
navContext: "nav-context",
navAgent: "nav-agent",
navProfiles: "nav-profiles",
settingsButton: "settings-button",
openProjectButton: "open-project-button",
// Views
boardView: "board-view",
specView: "spec-view",
contextView: "context-view",
agentView: "agent-view",
profilesView: "profiles-view",
settingsView: "settings-view",
welcomeView: "welcome-view",
setupView: "setup-view",
// Board View Components
addFeatureButton: "add-feature-button",
addFeatureDialog: "add-feature-dialog",
confirmAddFeature: "confirm-add-feature",
featureBranchInput: "feature-branch-input",
featureCategoryInput: "feature-category-input",
worktreeSelector: "worktree-selector",
// Spec Editor
specEditor: "spec-editor",
// File Browser Dialog
pathInput: "path-input",
goToPathButton: "go-to-path-button",
// Profiles View
addProfileButton: "add-profile-button",
addProfileDialog: "add-profile-dialog",
editProfileDialog: "edit-profile-dialog",
deleteProfileConfirmDialog: "delete-profile-confirm-dialog",
saveProfileButton: "save-profile-button",
confirmDeleteProfileButton: "confirm-delete-profile-button",
cancelDeleteButton: "cancel-delete-button",
profileNameInput: "profile-name-input",
profileDescriptionInput: "profile-description-input",
refreshProfilesButton: "refresh-profiles-button",
// Context View
contextFileList: "context-file-list",
addContextButton: "add-context-button",
} as const;
// ============================================================================
// CSS Selectors
// ============================================================================
/**
* Common CSS selectors for elements that don't have data-testid
*/
export const CSS_SELECTORS = {
/** CodeMirror editor content area */
codeMirrorContent: ".cm-content",
/** Dialog elements */
dialog: '[role="dialog"]',
/** Sonner toast notifications */
toast: "[data-sonner-toast]",
toastError: '[data-sonner-toast][data-type="error"]',
toastSuccess: '[data-sonner-toast][data-type="success"]',
/** Command/combobox input (shadcn-ui cmdk) */
commandInput: "[cmdk-input]",
/** Radix dialog overlay */
dialogOverlay: "[data-radix-dialog-overlay]",
} as const;
// ============================================================================
// Storage Keys
// ============================================================================
/**
* localStorage keys used by the application
*/
export const STORAGE_KEYS = {
appStorage: "automaker-storage",
setupStorage: "automaker-setup",
} as const;
// ============================================================================
// Branch Name Utilities
// ============================================================================
/**
* Sanitize a branch name to create a valid worktree directory name
* @param branchName - The branch name to sanitize
* @returns Sanitized name suitable for directory paths
*/
export function sanitizeBranchName(branchName: string): string {
return branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
}
// ============================================================================
// Default Values
// ============================================================================
/**
* Default values used in test setup
*/
export const DEFAULTS = {
projectName: "Test Project",
projectPath: "/mock/test-project",
theme: "dark" as const,
maxConcurrency: 3,
} as const;

View File

@@ -3,12 +3,22 @@ import { Page, Locator } from "@playwright/test";
/** /**
* Perform a drag and drop operation that works with @dnd-kit * Perform a drag and drop operation that works with @dnd-kit
* This uses explicit mouse movements with pointer events * This uses explicit mouse movements with pointer events
*
* NOTE: dnd-kit requires careful timing for drag activation. In CI environments,
* we need longer delays and more movement steps for reliable detection.
*/ */
export async function dragAndDropWithDndKit( export async function dragAndDropWithDndKit(
page: Page, page: Page,
sourceLocator: Locator, sourceLocator: Locator,
targetLocator: Locator targetLocator: Locator
): Promise<void> { ): Promise<void> {
// Ensure elements are visible and stable before getting bounding boxes
await sourceLocator.waitFor({ state: "visible", timeout: 5000 });
await targetLocator.waitFor({ state: "visible", timeout: 5000 });
// Small delay to ensure layout is stable
await page.waitForTimeout(100);
const sourceBox = await sourceLocator.boundingBox(); const sourceBox = await sourceLocator.boundingBox();
const targetBox = await targetLocator.boundingBox(); const targetBox = await targetLocator.boundingBox();
@@ -24,11 +34,29 @@ export async function dragAndDropWithDndKit(
const endX = targetBox.x + targetBox.width / 2; const endX = targetBox.x + targetBox.width / 2;
const endY = targetBox.y + targetBox.height / 2; const endY = targetBox.y + targetBox.height / 2;
// Perform the drag and drop with pointer events // Move to source element first
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.waitForTimeout(50);
// Press and hold - dnd-kit needs time to activate the drag sensor
await page.mouse.down(); await page.mouse.down();
await page.waitForTimeout(150); // Give dnd-kit time to recognize the drag await page.waitForTimeout(300); // Longer delay for CI - dnd-kit activation threshold
await page.mouse.move(endX, endY, { steps: 15 });
await page.waitForTimeout(100); // Allow time for drop detection // Move slightly first to trigger drag detection (dnd-kit has a distance threshold)
const smallMoveX = startX + 10;
const smallMoveY = startY + 10;
await page.mouse.move(smallMoveX, smallMoveY, { steps: 3 });
await page.waitForTimeout(100);
// Now move to target with slower, more deliberate movement
await page.mouse.move(endX, endY, { steps: 25 });
// Pause over target for drop detection
await page.waitForTimeout(200);
// Release
await page.mouse.up(); await page.mouse.up();
// Allow time for the drop handler to process
await page.waitForTimeout(100);
} }

View File

@@ -0,0 +1,474 @@
/**
* Git worktree utilities for testing
* Provides helpers for creating test git repos and managing worktrees
*/
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { Page } from "@playwright/test";
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
const execAsync = promisify(exec);
// ============================================================================
// Types
// ============================================================================
export interface TestRepo {
path: string;
cleanup: () => Promise<void>;
}
export interface FeatureData {
id: string;
category: string;
description: string;
status: string;
branchName?: string;
worktreePath?: string;
}
// ============================================================================
// Path Utilities
// ============================================================================
/**
* Get the workspace root directory (internal use only)
* Note: Also exported from project/fixtures.ts for broader use
*/
function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/app")) {
return path.resolve(cwd, "../..");
}
return cwd;
}
/**
* Create a unique temp directory path for tests
*/
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
}
/**
* Get the expected worktree path for a branch
*/
export function getWorktreePath(projectPath: string, branchName: string): string {
const sanitizedName = sanitizeBranchName(branchName);
return path.join(projectPath, ".worktrees", sanitizedName);
}
// ============================================================================
// Git Repository Management
// ============================================================================
/**
* Create a temporary git repository for testing
*/
export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
// Create temp directory if it doesn't exist
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tmpDir = path.join(tempDir, `test-repo-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Initialize git repo
await execAsync("git init", { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
await execAsync("git add .", { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir });
// Create .automaker directories
const automakerDir = path.join(tmpDir, ".automaker");
const featuresDir = path.join(automakerDir, "features");
fs.mkdirSync(featuresDir, { recursive: true });
// Create empty categories.json to avoid ENOENT errors in tests
fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]");
return {
path: tmpDir,
cleanup: async () => {
await cleanupTestRepo(tmpDir);
},
};
}
/**
* Cleanup a test git repository
*/
export async function cleanupTestRepo(repoPath: string): Promise<void> {
try {
// Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
}).catch(() => ({ stdout: "" }));
const worktrees = stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean);
for (const worktreePath of worktrees) {
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: repoPath,
});
} catch {
// Ignore errors
}
}
// Remove the repository
fs.rmSync(repoPath, { recursive: true, force: true });
} catch (error) {
console.error("Failed to cleanup test repo:", error);
}
}
/**
* Cleanup a temp directory and all its contents
*/
export function cleanupTempDir(tempDir: string): void {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
// ============================================================================
// Git Operations
// ============================================================================
/**
* Execute a git command in a repository
*/
export async function gitExec(
repoPath: string,
command: string
): Promise<{ stdout: string; stderr: string }> {
return execAsync(`git ${command}`, { cwd: repoPath });
}
/**
* Get list of git worktrees
*/
export async function listWorktrees(repoPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
});
return stdout
.split("\n\n")
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
})
.filter(Boolean) as string[];
} catch {
return [];
}
}
/**
* Get list of git branches
*/
export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
return stdout
.split("\n")
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
.filter(Boolean);
}
/**
* Get the current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
return stdout.trim();
}
/**
* Create a git branch
*/
export async function createBranch(repoPath: string, branchName: string): Promise<void> {
await execAsync(`git branch ${branchName}`, { cwd: repoPath });
}
/**
* Checkout a git branch
*/
export async function checkoutBranch(repoPath: string, branchName: string): Promise<void> {
await execAsync(`git checkout ${branchName}`, { cwd: repoPath });
}
/**
* Create a git worktree using git command directly
*/
export async function createWorktreeDirectly(
repoPath: string,
branchName: string,
worktreePath?: string
): Promise<string> {
const sanitizedName = sanitizeBranchName(branchName);
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
return targetPath;
}
/**
* Add and commit a file
*/
export async function commitFile(
repoPath: string,
filePath: string,
content: string,
message: string
): Promise<void> {
fs.writeFileSync(path.join(repoPath, filePath), content);
await execAsync(`git add "${filePath}"`, { cwd: repoPath });
await execAsync(`git commit -m "${message}"`, { cwd: repoPath });
}
/**
* Get the latest commit message
*/
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
return stdout.trim();
}
// ============================================================================
// Feature File Management
// ============================================================================
/**
* Create a feature file in the test repo
*/
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
const featuresDir = path.join(repoPath, ".automaker", "features");
const featureDir = path.join(featuresDir, featureId);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
}
/**
* Read a feature file from the test repo
*/
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
if (!fs.existsSync(featureFilePath)) {
return null;
}
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
}
/**
* List all feature directories in the test repo
*/
export function listTestFeatures(repoPath: string): string[] {
const featuresDir = path.join(repoPath, ".automaker", "features");
if (!fs.existsSync(featuresDir)) {
return [];
}
return fs.readdirSync(featuresDir);
}
// ============================================================================
// Project Setup for Tests
// ============================================================================
/**
* Set up localStorage with a project pointing to a test repo
*/
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-worktree",
name: "Worktree Test Project",
path: pathArg,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
},
worktreesByProject: {},
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
/**
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
* Use this to test scenarios where the worktree feature flag is off
*/
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-no-worktree",
name: "Test Project (No Worktrees)",
path: pathArg,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: false, // Worktree feature DISABLED
currentWorktreeByProject: {},
worktreesByProject: {},
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
/**
* Set up localStorage with a project that has STALE worktree data
* The currentWorktreeByProject points to a worktree path that no longer exists
* This simulates the scenario where a user previously selected a worktree that was later deleted
*/
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-stale-worktree",
name: "Stale Worktree Test Project",
path: pathArg,
lastOpened: new Date().toISOString(),
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
// This is STALE data - pointing to a worktree path that doesn't exist
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
},
worktreesByProject: {},
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
}, projectPath);
}
// ============================================================================
// Wait Utilities
// ============================================================================
/**
* Wait for the board view to load
*/
export async function waitForBoardView(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="board-view"]', { timeout: TIMEOUTS.long });
}
/**
* Wait for the worktree selector to be visible
*/
export async function waitForWorktreeSelector(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
});
}

View File

@@ -4,6 +4,13 @@
export * from "./core/elements"; export * from "./core/elements";
export * from "./core/interactions"; export * from "./core/interactions";
export * from "./core/waiting"; export * from "./core/waiting";
export * from "./core/constants";
// API utilities
export * from "./api/client";
// Git utilities
export * from "./git/worktree";
// Project utilities // Project utilities
export * from "./project/setup"; export * from "./project/setup";

View File

@@ -110,3 +110,117 @@ export async function getDragHandleForFeature(
): Promise<Locator> { ): Promise<Locator> {
return page.locator(`[data-testid="drag-handle-${featureId}"]`); return page.locator(`[data-testid="drag-handle-${featureId}"]`);
} }
// ============================================================================
// Add Feature Dialog
// ============================================================================
/**
* Click the add feature button
*/
export async function clickAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="add-feature-button"]');
await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 });
}
/**
* Fill in the add feature dialog
*/
export async function fillAddFeatureDialog(
page: Page,
description: string,
options?: { branch?: string; category?: string }
): Promise<void> {
// Fill description (using the dropzone textarea)
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) {
const branchButton = page.locator('[data-testid="feature-branch-input"]');
await branchButton.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
// Wait for popover to close
await page.waitForTimeout(200);
}
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
const categoryButton = page.locator('[data-testid="feature-category-input"]');
await categoryButton.click();
await page.waitForTimeout(300);
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.category);
await commandInput.press("Enter");
await page.waitForTimeout(200);
}
}
/**
* Confirm the add feature dialog
*/
export async function confirmAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="confirm-add-feature"]');
// Wait for dialog to close
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
{ timeout: 5000 }
);
}
/**
* Add a feature with all steps in one call
*/
export async function addFeature(
page: Page,
description: string,
options?: { branch?: string; category?: string }
): Promise<void> {
await clickAddFeature(page);
await fillAddFeatureDialog(page, description, options);
await confirmAddFeature(page);
}
// ============================================================================
// Worktree Selector
// ============================================================================
/**
* Get the worktree selector element
*/
export async function getWorktreeSelector(page: Page): Promise<Locator> {
return page.locator('[data-testid="worktree-selector"]');
}
/**
* Click on a branch button in the worktree selector
*/
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update
}
/**
* Get the currently selected branch in the worktree selector
*/
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
// The main branch button has aria-pressed="true" when selected
const selectedButton = page.locator('[data-testid="worktree-selector"] button[aria-pressed="true"]');
const text = await selectedButton.textContent().catch(() => null);
return text?.trim() || null;
}
/**
* Check if a branch button is visible in the worktree selector
*/
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
return await branchButton.isVisible().catch(() => false);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
/**
* Automaker Paths - Utilities for managing automaker data storage
*
* Stores project data inside the project directory at {projectPath}/.automaker/
*/
import fs from "fs/promises";
import path from "path";
/**
* Get the automaker data directory for a project
* This is stored inside the project at .automaker/
*/
export function getAutomakerDir(projectPath: string): string {
return path.join(projectPath, ".automaker");
}
/**
* Get the features directory for a project
*/
export function getFeaturesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "features");
}
/**
* Get the directory for a specific feature
*/
export function getFeatureDir(projectPath: string, featureId: string): string {
return path.join(getFeaturesDir(projectPath), featureId);
}
/**
* Get the images directory for a feature
*/
export function getFeatureImagesDir(
projectPath: string,
featureId: string
): string {
return path.join(getFeatureDir(projectPath, featureId), "images");
}
/**
* Get the board directory for a project (board backgrounds, etc.)
*/
export function getBoardDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "board");
}
/**
* Get the images directory for a project (general images)
*/
export function getImagesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "images");
}
/**
* Get the worktrees metadata directory for a project
*/
export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "worktrees");
}
/**
* Get the app spec file path for a project
*/
export function getAppSpecPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
}
/**
* Get the branch tracking file path for a project
*/
export function getBranchTrackingPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "active-branches.json");
}
/**
* Ensure the automaker directory structure exists for a project
*/
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
const automakerDir = getAutomakerDir(projectPath);
await fs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}

View File

@@ -0,0 +1,221 @@
/**
* Dependency Resolution Utility (Server-side)
*
* Provides topological sorting and dependency analysis for features.
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
*/
import type { Feature } from "../services/feature-loader.js";
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
* Resolves feature dependencies using topological sort with priority-aware ordering.
*
* Algorithm:
* 1. Build dependency graph and detect missing/blocked dependencies
* 2. Apply Kahn's algorithm for topological sort
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
* 4. Detect circular dependencies for features that can't be ordered
*
* @param features - Array of features to order
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
const blockedFeatures = new Map<string, string[]>();
// Initialize graph structures
for (const feature of features) {
inDegree.set(feature.id, 0);
adjacencyList.set(feature.id, []);
}
// Build dependency graph and detect missing/blocked dependencies
for (const feature of features) {
const deps = feature.dependencies || [];
for (const depId of deps) {
if (!featureMap.has(depId)) {
// Missing dependency - track it
if (!missingDependencies.has(feature.id)) {
missingDependencies.set(feature.id, []);
}
missingDependencies.get(feature.id)!.push(depId);
} else {
// Valid dependency - add edge to graph
adjacencyList.get(depId)!.push(feature.id);
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
// Check if dependency is incomplete (blocking)
const depFeature = featureMap.get(depId)!;
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
if (!blockedFeatures.has(feature.id)) {
blockedFeatures.set(feature.id, []);
}
blockedFeatures.get(feature.id)!.push(depId);
}
}
}
}
// Kahn's algorithm with priority-aware selection
const queue: Feature[] = [];
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
if (degree === 0) {
queue.push(featureMap.get(id)!);
}
}
// Sort initial queue by priority
queue.sort(sortByPriority);
// Process features in topological order
while (queue.length > 0) {
// Take highest priority feature from queue
const current = queue.shift()!;
orderedFeatures.push(current);
// Process features that depend on this one
for (const dependentId of adjacencyList.get(current.id) || []) {
const currentDegree = inDegree.get(dependentId);
if (currentDegree === undefined) {
throw new Error(`In-degree not initialized for feature ${dependentId}`);
}
const newDegree = currentDegree - 1;
inDegree.set(dependentId, newDegree);
if (newDegree === 0) {
queue.push(featureMap.get(dependentId)!);
// Re-sort queue to maintain priority order
queue.sort(sortByPriority);
}
}
}
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
// Add remaining features at end (part of cycles)
orderedFeatures.push(...remaining);
}
return {
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
};
}
/**
* Detects circular dependencies using depth-first search
*
* @param features - Features that couldn't be topologically sorted (potential cycles)
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const currentPath: string[] = [];
function dfs(featureId: string): boolean {
visited.add(featureId);
recursionStack.add(featureId);
currentPath.push(featureId);
const feature = featureMap.get(featureId);
if (feature) {
for (const depId of feature.dependencies || []) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
// Found cycle - extract it
const cycleStart = currentPath.indexOf(depId);
cycles.push(currentPath.slice(cycleStart));
return true;
}
}
}
currentPath.pop();
recursionStack.delete(featureId);
return false;
}
for (const feature of features) {
if (!visited.has(feature.id)) {
dfs(feature.id);
}
}
return cycles;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
/**
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -0,0 +1,67 @@
/**
* File system utilities that handle symlinks safely
*/
import fs from "fs/promises";
import path from "path";
/**
* Create a directory, handling symlinks safely to avoid ELOOP errors.
* If the path already exists as a directory or symlink, returns success.
*/
export async function mkdirSafe(dirPath: string): Promise<void> {
const resolvedPath = path.resolve(dirPath);
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
return;
}
// It's a file - can't create directory
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== "ENOENT") {
// Some other error (could be ELOOP in parent path)
// If it's ELOOP, the path involves symlinks - don't try to create
if (error.code === "ELOOP") {
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
return;
}
throw error;
}
}
// Path doesn't exist, create it
try {
await fs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === "EEXIST" || error.code === "ELOOP") {
return;
}
throw error;
}
}
/**
* Check if a path exists, handling symlinks safely.
* Returns true if the path exists as a file, directory, or symlink.
*/
export async function existsSafe(filePath: string): Promise<boolean> {
try {
await fs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === "ENOENT") {
return false;
}
// ELOOP or other errors - path exists but is problematic
if (error.code === "ELOOP") {
return true; // Symlink exists, even if looping
}
throw error;
}
}

View File

@@ -3,13 +3,13 @@
*/ */
import { query } from "@anthropic-ai/claude-agent-sdk"; import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js"; import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js"; import { createLogger } from "../../lib/logger.js";
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js"; import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js"; import { logAuthStatus } from "./common.js";
import { parseAndCreateFeatures } from "./parse-and-create-features.js"; import { parseAndCreateFeatures } from "./parse-and-create-features.js";
import { getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration"); const logger = createLogger("SpecRegeneration");
@@ -26,8 +26,8 @@ export async function generateFeaturesFromSpec(
logger.debug("projectPath:", projectPath); logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount); logger.debug("maxFeatures:", featureCount);
// Read existing spec // Read existing spec from .automaker directory
const specPath = path.join(projectPath, ".automaker", "app_spec.txt"); const specPath = getAppSpecPath(projectPath);
let spec: string; let spec: string;
logger.debug("Reading spec from:", specPath); logger.debug("Reading spec from:", specPath);

View File

@@ -11,6 +11,7 @@ import { createLogger } from "../../lib/logger.js";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js"; import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js"; import { logAuthStatus } from "./common.js";
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js"; import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
import { ensureAutomakerDir, getAppSpecPath } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration"); const logger = createLogger("SpecRegeneration");
@@ -209,14 +210,13 @@ ${getAppSpecFormatInstruction()}`;
logger.error("❌ WARNING: responseText is empty! Nothing to save."); logger.error("❌ WARNING: responseText is empty! Nothing to save.");
} }
// Save spec // Save spec to .automaker directory
const specDir = path.join(projectPath, ".automaker"); const specDir = await ensureAutomakerDir(projectPath);
const specPath = path.join(specDir, "app_spec.txt"); const specPath = getAppSpecPath(projectPath);
logger.info("Saving spec to:", specPath); logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${responseText.length} chars)`); logger.info(`Content to save (${responseText.length} chars)`);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText); await fs.writeFile(specPath, responseText);
// Verify the file was written // Verify the file was written

View File

@@ -22,3 +22,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
return router; return router;
} }

View File

@@ -6,6 +6,7 @@ import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js"; import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js"; import { createLogger } from "../../lib/logger.js";
import { getFeaturesDir } from "../../lib/automaker-paths.js";
const logger = createLogger("SpecRegeneration"); const logger = createLogger("SpecRegeneration");
@@ -41,7 +42,7 @@ export async function parseAndCreateFeatures(
logger.info(`Parsed ${parsed.features?.length || 0} features`); logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2)); logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
const featuresDir = path.join(projectPath, ".automaker", "features"); const featuresDir = getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true }); await fs.mkdir(featuresDir, { recursive: true });
const createdFeatures: Array<{ id: string; title: string }> = []; const createdFeatures: Array<{ id: string; title: string }> = [];

View File

@@ -9,9 +9,10 @@ import { getErrorMessage, logError } from "../common.js";
export function createCommitFeatureHandler(autoModeService: AutoModeService) { export function createCommitFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId } = req.body as { const { projectPath, featureId, worktreePath } = req.body as {
projectPath: string; projectPath: string;
featureId: string; featureId: string;
worktreePath?: string;
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
@@ -26,7 +27,8 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) {
const commitHash = await autoModeService.commitFeature( const commitHash = await autoModeService.commitFeature(
projectPath, projectPath,
featureId featureId,
worktreePath
); );
res.json({ success: true, commitHash }); res.json({ success: true, commitHash });
} catch (error) { } catch (error) {

View File

@@ -12,11 +12,12 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId, prompt, imagePaths } = req.body as { const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
projectPath: string; projectPath: string;
featureId: string; featureId: string;
prompt: string; prompt: string;
imagePaths?: string[]; imagePaths?: string[];
worktreePath?: string;
}; };
if (!projectPath || !featureId || !prompt) { if (!projectPath || !featureId || !prompt) {
@@ -27,9 +28,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return; return;
} }
// Start follow-up in background // Start follow-up in background, using the feature's worktreePath for correct branch
autoModeService autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths) .followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
.catch((error) => { .catch((error) => {
logger.error( logger.error(
`[AutoMode] Follow up feature ${featureId} error:`, `[AutoMode] Follow up feature ${featureId} error:`,

View File

@@ -29,8 +29,9 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
} }
// Start resume in background // Start resume in background
// Default to false - worktrees should only be used when explicitly enabled
autoModeService autoModeService
.resumeFeature(projectPath, featureId, useWorktrees ?? true) .resumeFeature(projectPath, featureId, useWorktrees ?? false)
.catch((error) => { .catch((error) => {
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error); logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
}); });

View File

@@ -12,10 +12,11 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) { export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId, useWorktrees } = req.body as { const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
projectPath: string; projectPath: string;
featureId: string; featureId: string;
useWorktrees?: boolean; useWorktrees?: boolean;
worktreePath?: string;
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
@@ -29,8 +30,10 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
} }
// Start execution in background // Start execution in background
// If worktreePath is provided, use it directly; otherwise let the service decide
// Default to false - worktrees should only be used when explicitly enabled
autoModeService autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? true, false) .executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath)
.catch((error) => { .catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, error); logger.error(`[AutoMode] Feature ${featureId} error:`, error);
}); });

View File

@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
export function createDeleteBoardBackgroundHandler() { export function createDeleteBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -20,10 +21,11 @@ export function createDeleteBoardBackgroundHandler() {
return; return;
} }
const boardDir = path.join(projectPath, ".automaker", "board"); // Get board directory
const boardDir = getBoardDir(projectPath);
try { try {
// Try to remove all files in the board directory // Try to remove all background files in the board directory
const files = await fs.readdir(boardDir); const files = await fs.readdir(boardDir);
for (const file of files) { for (const file of files) {
if (file.startsWith("background")) { if (file.startsWith("background")) {

View File

@@ -1,5 +1,6 @@
/** /**
* POST /mkdir endpoint - Create directory * POST /mkdir endpoint - Create directory
* Handles symlinks safely to avoid ELOOP errors
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@@ -20,13 +21,46 @@ export function createMkdirHandler() {
const resolvedPath = path.resolve(dirPath); const resolvedPath = path.resolve(dirPath);
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await fs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
addAllowedPath(resolvedPath);
res.json({ success: true });
return;
}
// It's a file - can't create directory
res.status(400).json({
success: false,
error: "Path exists and is not a directory",
});
return;
} catch (statError: any) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== "ENOENT") {
// Some other error (could be ELOOP in parent path)
throw statError;
}
}
// Path doesn't exist, create it
await fs.mkdir(resolvedPath, { recursive: true }); await fs.mkdir(resolvedPath, { recursive: true });
// Add the new directory to allowed paths for tracking // Add the new directory to allowed paths for tracking
addAllowedPath(resolvedPath); addAllowedPath(resolvedPath);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error: any) {
// Handle ELOOP specifically
if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected");
res.status(400).json({
success: false,
error: "Cannot create directory: symlink loop detected in path",
});
return;
}
logError(error, "Create directory failed"); logError(error, "Create directory failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -7,6 +7,23 @@ import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js"; import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
// Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ["categories.json"];
function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
}
function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
}
export function createReadHandler() { export function createReadHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -22,7 +39,11 @@ export function createReadHandler() {
res.json({ success: true, content }); res.json({ success: true, content });
} catch (error) { } catch (error) {
logError(error, "Read file failed"); // Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
if (shouldLog) {
logError(error, "Read file failed");
}
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }
}; };

View File

@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import { addAllowedPath } from "../../../lib/security.js"; import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { getBoardDir } from "../../../lib/automaker-paths.js";
export function createSaveBoardBackgroundHandler() { export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -26,8 +27,8 @@ export function createSaveBoardBackgroundHandler() {
return; return;
} }
// Create .automaker/board directory if it doesn't exist // Get board directory
const boardDir = path.join(projectPath, ".automaker", "board"); const boardDir = getBoardDir(projectPath);
await fs.mkdir(boardDir, { recursive: true }); await fs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present) // Decode base64 data (remove data URL prefix if present)
@@ -42,12 +43,11 @@ export function createSaveBoardBackgroundHandler() {
// Write file // Write file
await fs.writeFile(filePath, buffer); await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already // Add board directory to allowed paths
addAllowedPath(projectPath); addAllowedPath(boardDir);
// Return the relative path for storage // Return the absolute path
const relativePath = `.automaker/board/${uniqueFilename}`; res.json({ success: true, path: filePath });
res.json({ success: true, path: relativePath });
} catch (error) { } catch (error) {
logError(error, "Save board background failed"); logError(error, "Save board background failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -1,5 +1,5 @@
/** /**
* POST /save-image endpoint - Save image to .automaker/images directory * POST /save-image endpoint - Save image to .automaker images directory
*/ */
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import { addAllowedPath } from "../../../lib/security.js"; import { addAllowedPath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { getImagesDir } from "../../../lib/automaker-paths.js";
export function createSaveImageHandler() { export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -26,8 +27,8 @@ export function createSaveImageHandler() {
return; return;
} }
// Create .automaker/images directory if it doesn't exist // Get images directory
const imagesDir = path.join(projectPath, ".automaker", "images"); const imagesDir = getImagesDir(projectPath);
await fs.mkdir(imagesDir, { recursive: true }); await fs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present) // Decode base64 data (remove data URL prefix if present)
@@ -44,9 +45,10 @@ export function createSaveImageHandler() {
// Write file // Write file
await fs.writeFile(filePath, buffer); await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already // Add automaker directory to allowed paths
addAllowedPath(projectPath); addAllowedPath(imagesDir);
// Return the absolute path
res.json({ success: true, path: filePath }); res.json({ success: true, path: filePath });
} catch (error) { } catch (error) {
logError(error, "Save image failed"); logError(error, "Save image failed");

View File

@@ -7,6 +7,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import { validatePath } from "../../../lib/security.js"; import { validatePath } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { mkdirSafe } from "../../../lib/fs-utils.js";
export function createWriteHandler() { export function createWriteHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -23,8 +24,8 @@ export function createWriteHandler() {
const resolvedPath = validatePath(filePath); const resolvedPath = validatePath(filePath);
// Ensure parent directory exists // Ensure parent directory exists (symlink-safe)
await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await mkdirSafe(path.dirname(resolvedPath));
await fs.writeFile(resolvedPath, content, "utf-8"); await fs.writeFile(resolvedPath, content, "utf-8");
res.json({ success: true }); res.json({ success: true });

View File

@@ -11,6 +11,7 @@ import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js";
import { createApiKeysHandler } from "./routes/api-keys.js"; import { createApiKeysHandler } from "./routes/api-keys.js";
import { createPlatformHandler } from "./routes/platform.js"; import { createPlatformHandler } from "./routes/platform.js";
import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js"; import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js";
import { createGhStatusHandler } from "./routes/gh-status.js";
export function createSetupRoutes(): Router { export function createSetupRoutes(): Router {
const router = Router(); const router = Router();
@@ -23,6 +24,7 @@ export function createSetupRoutes(): Router {
router.get("/api-keys", createApiKeysHandler()); router.get("/api-keys", createApiKeysHandler());
router.get("/platform", createPlatformHandler()); router.get("/platform", createPlatformHandler());
router.post("/verify-claude-auth", createVerifyClaudeAuthHandler()); router.post("/verify-claude-auth", createVerifyClaudeAuthHandler());
router.get("/gh-status", createGhStatusHandler());
return router; return router;
} }

View File

@@ -102,3 +102,4 @@ export function createDeleteApiKeyHandler() {
}; };
} }

View File

@@ -0,0 +1,131 @@
/**
* GET /gh-status endpoint - Get GitHub CLI status
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import os from "os";
import path from "path";
import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
"/opt/homebrew/bin",
"/usr/local/bin",
"/home/linuxbrew/.linuxbrew/bin",
`${process.env.HOME}/.local/bin`,
].filter(Boolean).join(":");
const execEnv = {
...process.env,
PATH: extendedPath,
};
export interface GhStatus {
installed: boolean;
authenticated: boolean;
version: string | null;
path: string | null;
user: string | null;
error?: string;
}
async function getGhStatus(): Promise<GhStatus> {
const status: GhStatus = {
installed: false,
authenticated: false,
version: null,
path: null,
user: null,
};
const isWindows = process.platform === "win32";
// Check if gh CLI is installed
try {
const findCommand = isWindows ? "where gh" : "command -v gh";
const { stdout } = await execAsync(findCommand, { env: execEnv });
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"),
path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"),
]
: [
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
path.join(os.homedir(), ".local", "bin", "gh"),
"/home/linuxbrew/.linuxbrew/bin/gh",
];
for (const p of commonPaths) {
try {
await fs.access(p);
status.path = p;
status.installed = true;
break;
} catch {
// Not found at this path
}
}
}
if (!status.installed) {
return status;
}
// Get version
try {
const { stdout } = await execAsync("gh --version", { env: execEnv });
// Extract version from output like "gh version 2.40.1 (2024-01-09)"
const versionMatch = stdout.match(/gh version ([\d.]+)/);
status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0];
} catch {
// Version command failed
}
// Check authentication status
try {
const { stdout } = await execAsync("gh auth status", { env: execEnv });
// If this succeeds without error, we're authenticated
status.authenticated = true;
// Try to extract username from output
const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
}
} catch (error: unknown) {
// Auth status returns non-zero if not authenticated
const err = error as { stderr?: string };
if (err.stderr?.includes("not logged in")) {
status.authenticated = false;
}
}
return status;
}
export function createGhStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const status = await getGhStatus();
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, "Get GitHub CLI status failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -13,6 +13,15 @@ import {
const logger = createLogger("Worktree"); const logger = createLogger("Worktree");
const execAsync = promisify(exec); const execAsync = promisify(exec);
/**
* Normalize path separators to forward slashes for cross-platform consistency.
* This ensures paths from `path.join()` (backslashes on Windows) match paths
* from git commands (which may use forward slashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
}
/** /**
* Check if a path is a git repo * Check if a path is a git repo
*/ */
@@ -25,6 +34,42 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
} }
} }
/**
* Check if an error is ENOENT (file/path not found or spawn failed)
* These are expected in test environments with mock paths
*/
export function isENOENT(error: unknown): boolean {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
);
}
/**
* Check if a path is a mock/test path that doesn't exist
*/
export function isMockPath(worktreePath: string): boolean {
return worktreePath.startsWith("/mock/") || worktreePath.includes("/mock/");
}
/**
* Conditionally log worktree errors - suppress ENOENT for mock paths
* to reduce noise in test output
*/
export function logWorktreeError(
error: unknown,
message: string,
worktreePath?: string
): void {
// Don't log ENOENT errors for mock paths (expected in tests)
if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) {
return;
}
logError(error, message);
}
// Re-export shared utilities // Re-export shared utilities
export { getErrorMessageShared as getErrorMessage }; export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger); export const logError = createLogError(logger);

View File

@@ -8,8 +8,25 @@ import { createStatusHandler } from "./routes/status.js";
import { createListHandler } from "./routes/list.js"; import { createListHandler } from "./routes/list.js";
import { createDiffsHandler } from "./routes/diffs.js"; import { createDiffsHandler } from "./routes/diffs.js";
import { createFileDiffHandler } from "./routes/file-diff.js"; import { createFileDiffHandler } from "./routes/file-diff.js";
import { createRevertHandler } from "./routes/revert.js";
import { createMergeHandler } from "./routes/merge.js"; import { createMergeHandler } from "./routes/merge.js";
import { createCreateHandler } from "./routes/create.js";
import { createDeleteHandler } from "./routes/delete.js";
import { createCreatePRHandler } from "./routes/create-pr.js";
import { createCommitHandler } from "./routes/commit.js";
import { createPushHandler } from "./routes/push.js";
import { createPullHandler } from "./routes/pull.js";
import { createCheckoutBranchHandler } from "./routes/checkout-branch.js";
import { createListBranchesHandler } from "./routes/list-branches.js";
import { createSwitchBranchHandler } from "./routes/switch-branch.js";
import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
} from "./routes/open-in-editor.js";
import { createInitGitHandler } from "./routes/init-git.js";
import { createMigrateHandler } from "./routes/migrate.js";
import { createStartDevHandler } from "./routes/start-dev.js";
import { createStopDevHandler } from "./routes/stop-dev.js";
import { createListDevServersHandler } from "./routes/list-dev-servers.js";
export function createWorktreeRoutes(): Router { export function createWorktreeRoutes(): Router {
const router = Router(); const router = Router();
@@ -19,8 +36,23 @@ export function createWorktreeRoutes(): Router {
router.post("/list", createListHandler()); router.post("/list", createListHandler());
router.post("/diffs", createDiffsHandler()); router.post("/diffs", createDiffsHandler());
router.post("/file-diff", createFileDiffHandler()); router.post("/file-diff", createFileDiffHandler());
router.post("/revert", createRevertHandler());
router.post("/merge", createMergeHandler()); router.post("/merge", createMergeHandler());
router.post("/create", createCreateHandler());
router.post("/delete", createDeleteHandler());
router.post("/create-pr", createCreatePRHandler());
router.post("/commit", createCommitHandler());
router.post("/push", createPushHandler());
router.post("/pull", createPullHandler());
router.post("/checkout-branch", createCheckoutBranchHandler());
router.post("/list-branches", createListBranchesHandler());
router.post("/switch-branch", createSwitchBranchHandler());
router.post("/open-in-editor", createOpenInEditorHandler());
router.get("/default-editor", createGetDefaultEditorHandler());
router.post("/init-git", createInitGitHandler());
router.post("/migrate", createMigrateHandler());
router.post("/start-dev", createStartDevHandler());
router.post("/stop-dev", createStopDevHandler());
router.post("/list-dev-servers", createListDevServersHandler());
return router; return router;
} }

View File

@@ -0,0 +1,123 @@
/**
* Branch tracking utilities
*
* Tracks active branches in .automaker so users
* can switch between branches even after worktrees are removed.
*/
import { readFile, writeFile } from "fs/promises";
import path from "path";
import {
getBranchTrackingPath,
ensureAutomakerDir,
} from "../../../lib/automaker-paths.js";
export interface TrackedBranch {
name: string;
createdAt: string;
lastActivatedAt?: string;
}
interface BranchTrackingData {
branches: TrackedBranch[];
}
/**
* Read tracked branches from file
*/
export async function getTrackedBranches(
projectPath: string
): Promise<TrackedBranch[]> {
try {
const filePath = getBranchTrackingPath(projectPath);
const content = await readFile(filePath, "utf-8");
const data: BranchTrackingData = JSON.parse(content);
return data.branches || [];
} catch (error: any) {
if (error.code === "ENOENT") {
return [];
}
console.warn("[branch-tracking] Failed to read tracked branches:", error);
return [];
}
}
/**
* Save tracked branches to file
*/
async function saveTrackedBranches(
projectPath: string,
branches: TrackedBranch[]
): Promise<void> {
const automakerDir = await ensureAutomakerDir(projectPath);
const filePath = path.join(automakerDir, "active-branches.json");
const data: BranchTrackingData = { branches };
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
}
/**
* Add a branch to tracking
*/
export async function trackBranch(
projectPath: string,
branchName: string
): Promise<void> {
const branches = await getTrackedBranches(projectPath);
// Check if already tracked
const existing = branches.find((b) => b.name === branchName);
if (existing) {
return; // Already tracked
}
branches.push({
name: branchName,
createdAt: new Date().toISOString(),
});
await saveTrackedBranches(projectPath, branches);
console.log(`[branch-tracking] Now tracking branch: ${branchName}`);
}
/**
* Remove a branch from tracking
*/
export async function untrackBranch(
projectPath: string,
branchName: string
): Promise<void> {
const branches = await getTrackedBranches(projectPath);
const filtered = branches.filter((b) => b.name !== branchName);
if (filtered.length !== branches.length) {
await saveTrackedBranches(projectPath, filtered);
console.log(`[branch-tracking] Stopped tracking branch: ${branchName}`);
}
}
/**
* Update last activated timestamp for a branch
*/
export async function updateBranchActivation(
projectPath: string,
branchName: string
): Promise<void> {
const branches = await getTrackedBranches(projectPath);
const branch = branches.find((b) => b.name === branchName);
if (branch) {
branch.lastActivatedAt = new Date().toISOString();
await saveTrackedBranches(projectPath, branches);
}
}
/**
* Check if a branch is tracked
*/
export async function isBranchTracked(
projectPath: string,
branchName: string
): Promise<boolean> {
const branches = await getTrackedBranches(projectPath);
return branches.some((b) => b.name === branchName);
}

View File

@@ -0,0 +1,86 @@
/**
* POST /checkout-branch endpoint - Create and checkout a new branch
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
worktreePath: string;
branchName: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
if (!branchName) {
res.status(400).json({
success: false,
error: "branchName required",
});
return;
}
// Validate branch name (basic validation)
const invalidChars = /[\s~^:?*\[\\]/;
if (invalidChars.test(branchName)) {
res.status(400).json({
success: false,
error: "Branch name contains invalid characters",
});
return;
}
// Get current branch for reference
const { stdout: currentBranchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const currentBranch = currentBranchOutput.trim();
// Check if branch already exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: worktreePath,
});
// Branch exists
res.status(400).json({
success: false,
error: `Branch '${branchName}' already exists`,
});
return;
} catch {
// Branch doesn't exist, good to create
}
// Create and checkout the new branch
await execAsync(`git checkout -b ${branchName}`, {
cwd: worktreePath,
});
res.json({
success: true,
result: {
previousBranch: currentBranch,
newBranch: branchName,
message: `Created and checked out branch '${branchName}'`,
},
});
} catch (error) {
logError(error, "Checkout branch failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /commit endpoint - Commit changes in a worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
export function createCommitHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message } = req.body as {
worktreePath: string;
message: string;
};
if (!worktreePath || !message) {
res.status(400).json({
success: false,
error: "worktreePath and message required",
});
return;
}
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
committed: false,
message: "No changes to commit",
},
});
return;
}
// Stage all changes
await execAsync("git add -A", { cwd: worktreePath });
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
});
// Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
cwd: worktreePath,
});
const commitHash = hashOutput.trim().substring(0, 8);
// Get branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
res.json({
success: true,
result: {
committed: true,
commitHash,
branch: branchName,
message,
},
});
} catch (error) {
logError(error, "Commit worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,288 @@
/**
* POST /create-pr endpoint - Commit changes and create a pull request from a worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
// This is needed because Electron apps don't inherit the user's shell PATH
const pathSeparator = process.platform === "win32" ? ";" : ":";
const additionalPaths: string[] = [];
if (process.platform === "win32") {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env["ProgramFiles(x86)"]) {
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
"/opt/homebrew/bin", // Homebrew on Apple Silicon
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
`${process.env.HOME}/.local/bin`, // pipx, other user installs
);
}
const extendedPath = [
process.env.PATH,
...additionalPaths.filter(Boolean),
].filter(Boolean).join(pathSeparator);
const execEnv = {
...process.env,
PATH: extendedPath,
};
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
worktreePath: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: "worktreePath required",
});
return;
}
// Get current branch name
const { stdout: branchOutput } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: worktreePath, env: execEnv }
);
const branchName = branchOutput.trim();
// Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath,
env: execEnv,
});
const hasChanges = status.trim().length > 0;
// If there are changes, commit them
let commitHash: string | null = null;
if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`;
// Stage all changes
await execAsync("git add -A", { cwd: worktreePath, env: execEnv });
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
env: execEnv,
});
// Get commit hash
const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", {
cwd: worktreePath,
env: execEnv,
});
commitHash = hashOutput.trim().substring(0, 8);
}
// Push the branch to remote
let pushError: string | null = null;
try {
await execAsync(`git push -u origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch (error: unknown) {
// If push fails, try with --set-upstream
try {
await execAsync(`git push --set-upstream origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch (error2: unknown) {
// Capture push error for reporting
const err = error2 as { stderr?: string; message?: string };
pushError = err.stderr || err.message || "Push failed";
console.error("[CreatePR] Push failed:", pushError);
}
}
// If push failed, return error
if (pushError) {
res.status(500).json({
success: false,
error: `Failed to push branch: ${pushError}`,
});
return;
}
// Create PR using gh CLI or provide browser fallback
const base = baseBranch || "main";
const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? "--draft" : "";
let prUrl: string | null = null;
let prError: string | null = null;
let browserUrl: string | null = null;
let ghCliAvailable = false;
// Check if gh CLI is available (cross-platform)
try {
const checkCommand = process.platform === "win32"
? "where gh"
: "command -v gh";
await execAsync(checkCommand, { env: execEnv });
ghCliAvailable = true;
} catch {
ghCliAvailable = false;
}
// Get repository URL for browser fallback
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync("git remote -v", {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
for (const line of lines) {
// Try multiple patterns to match different remote URL formats
// Pattern 1: git@github.com:owner/repo.git (fetch)
// Pattern 2: https://github.com/owner/repo.git (fetch)
// Pattern 3: https://github.com/owner/repo (fetch)
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (!match) {
// Try SSH format: git@github.com:owner/repo.git
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === "upstream") {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === "origin") {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch (error) {
// Couldn't parse remotes - will try fallback
}
// Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) {
try {
const { stdout: originUrl } = await execAsync("git config --get remote.origin.url", {
cwd: worktreePath,
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
originOwner = owner;
repoUrl = `https://github.com/${owner}/${repo}`;
}
} catch (error) {
// Failed to get repo URL from config
}
}
// Construct browser URL for PR creation
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
if (upstreamRepo && originOwner) {
// Fork workflow: PR to upstream from origin
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} else {
// Regular repo
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
}
}
if (ghCliAvailable) {
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
const { stdout: prOutput } = await execAsync(prCmd, {
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
} catch (ghError: unknown) {
// gh CLI failed
const err = ghError as { stderr?: string; message?: string };
prError = err.stderr || err.message || "PR creation failed";
}
} else {
prError = "gh_cli_not_available";
}
// Return result with browser fallback URL
res.json({
success: true,
result: {
branch: branchName,
committed: hasChanges,
commitHash,
pushed: true,
prUrl,
prCreated: !!prUrl,
prError: prError || undefined,
browserUrl: browserUrl || undefined,
ghCliAvailable,
},
});
} catch (error) {
logError(error, "Create PR failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,172 @@
/**
* POST /create endpoint - Create a new git worktree
*
* This endpoint handles worktree creation with proper checks:
* 1. First checks if git already has a worktree for the branch (anywhere)
* 2. If found, returns the existing worktree (no error)
* 3. Only creates a new worktree if none exists for the branch
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import { mkdir } from "fs/promises";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
import { trackBranch } from "./branch-tracking.js";
const execAsync = promisify(exec);
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
async function findExistingWorktreeForBranch(
projectPath: string,
branchName: string
): Promise<{ path: string; branch: string } | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: projectPath,
});
const lines = stdout.split("\n");
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentPath = line.slice(9);
} else if (line.startsWith("branch ")) {
currentBranch = line.slice(7).replace("refs/heads/", "");
} else if (line === "" && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
// Critical for cross-platform compatibility (Windows, macOS, Linux)
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return { path: resolvedPath, branch: currentBranch };
}
currentPath = null;
currentBranch = null;
}
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
// Resolve to absolute path for cross-platform compatibility
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return { path: resolvedPath, branch: currentBranch };
}
return null;
} catch {
return null;
}
}
export function createCreateHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
projectPath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
};
if (!projectPath || !branchName) {
res.status(400).json({
success: false,
error: "projectPath and branchName required",
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
});
return;
}
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);
if (existingWorktree) {
// Worktree already exists, return it as success (not an error)
// This handles manually created worktrees or worktrees from previous runs
console.log(`[Worktree] Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}`);
// Track the branch so it persists in the UI
await trackBranch(projectPath, branchName);
res.json({
success: true,
worktree: {
path: normalizePath(existingWorktree.path),
branch: branchName,
isNew: false, // Not newly created
},
});
return;
}
// Sanitize branch name for directory usage
const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, sanitizedName);
// Create worktrees directory if it doesn't exist
await mkdir(worktreesDir, { recursive: true });
// Check if branch exists
let branchExists = false;
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree
let createCmd: string;
if (branchExists) {
// Use existing branch
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || "HEAD";
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
await execAsync(createCmd, { cwd: projectPath });
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
// Track the branch so it persists in the UI even after worktree is removed
await trackBranch(projectPath, branchName);
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
res.json({
success: true,
worktree: {
path: normalizePath(absoluteWorktreePath),
branch: branchName,
isNew: !branchExists,
},
});
} catch (error) {
logError(error, "Create worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /delete endpoint - Delete a git worktree
*/
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { isGitRepo, getErrorMessage, logError } from "../common.js";
const execAsync = promisify(exec);
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, deleteBranch } = req.body as {
projectPath: string;
worktreePath: string;
deleteBranch?: boolean; // Whether to also delete the branch
};
if (!projectPath || !worktreePath) {
res.status(400).json({
success: false,
error: "projectPath and worktreePath required",
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
error: "Not a git repository",
});
return;
}
// Get branch name before removing worktree
let branchName: string | null = null;
try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
});
branchName = stdout.trim();
} catch {
// Could not get branch name
}
// Remove the worktree
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
} catch (error) {
// Try with prune if remove fails
await execAsync("git worktree prune", { cwd: projectPath });
}
// Optionally delete the branch
if (deleteBranch && branchName && branchName !== "main" && branchName !== "master") {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Branch deletion failed, not critical
}
}
res.json({
success: true,
deleted: {
worktreePath,
branch: deleteBranch ? branchName : null,
},
});
} catch (error) {
logError(error, "Delete worktree failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -26,12 +26,8 @@ export function createDiffsHandler() {
return; return;
} }
const worktreePath = path.join( // Git worktrees are stored in project directory
projectPath, const worktreePath = path.join(projectPath, ".worktrees", featureId);
".automaker",
"worktrees",
featureId
);
try { try {
// Check if worktree exists // Check if worktree exists

View File

@@ -29,12 +29,8 @@ export function createFileDiffHandler() {
return; return;
} }
const worktreePath = path.join( // Git worktrees are stored in project directory
projectPath, const worktreePath = path.join(projectPath, ".worktrees", featureId);
".automaker",
"worktrees",
featureId
);
try { try {
await fs.access(worktreePath); await fs.access(worktreePath);

View File

@@ -7,7 +7,7 @@ import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError, normalizePath } from "../common.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -29,13 +29,8 @@ export function createInfoHandler() {
return; return;
} }
// Check if worktree exists // Check if worktree exists (git worktrees are stored in project directory)
const worktreePath = path.join( const worktreePath = path.join(projectPath, ".worktrees", featureId);
projectPath,
".automaker",
"worktrees",
featureId
);
try { try {
await fs.access(worktreePath); await fs.access(worktreePath);
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
@@ -43,7 +38,7 @@ export function createInfoHandler() {
}); });
res.json({ res.json({
success: true, success: true,
worktreePath, worktreePath: normalizePath(worktreePath),
branchName: stdout.trim(), branchName: stdout.trim(),
}); });
} catch { } catch {

Some files were not shown because too many files have changed in this diff Show More