Merge pull request #161 from AutoMaker-Org/worktree-confusion

feat: enhance UI components and branch management
This commit is contained in:
Web Dev Cody
2025-12-18 00:33:25 -05:00
committed by GitHub
52 changed files with 1449 additions and 1003 deletions

View File

@@ -45,9 +45,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",

View File

@@ -3,14 +3,15 @@ import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
const mockAgent =
process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: undefined,
reporter: "html",
timeout: 30000,
use: {

View File

@@ -35,6 +35,7 @@ interface AutocompleteProps {
emptyMessage?: string;
className?: string;
disabled?: boolean;
error?: boolean;
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
@@ -58,6 +59,7 @@ export function Autocomplete({
emptyMessage = "No results found.",
className,
disabled = false,
error = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
@@ -130,6 +132,7 @@ export function Autocomplete({
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
error && "border-destructive focus-visible:ring-destructive",
className
)}
data-testid={testId}

View File

@@ -11,6 +11,7 @@ interface BranchAutocompleteProps {
placeholder?: string;
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
}
@@ -21,6 +22,7 @@ export function BranchAutocomplete({
placeholder = "Select a branch...",
className,
disabled = false,
error = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
@@ -43,6 +45,7 @@ export function BranchAutocomplete({
emptyMessage="No branches found."
className={className}
disabled={disabled}
error={error}
icon={GitBranch}
allowCreate
createLabel={(v) => `Create "${v}"`}

View File

@@ -87,16 +87,18 @@ function DialogOverlay({
);
}
function DialogContent({
className,
children,
showCloseButton = true,
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
export type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"ref"
> & {
showCloseButton?: boolean;
compact?: boolean;
}) {
};
const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
@@ -105,6 +107,7 @@ function DialogContent({
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
@@ -148,7 +151,9 @@ function DialogContent({
</DialogContentPrimitive>
</DialogPortal>
);
}
});
DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {
PointerSensor,
useSensor,
@@ -10,7 +10,9 @@ import {
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -25,7 +27,7 @@ import {
AddFeatureDialog,
AgentOutputModal,
CompletedFeaturesModal,
DeleteAllVerifiedDialog,
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
@@ -76,6 +78,10 @@ export function BoardView() {
setCurrentWorktree,
getWorktrees,
setWorktrees,
useWorktrees,
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -93,7 +99,7 @@ export function BoardView() {
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set()
);
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
@@ -285,6 +291,27 @@ export function BoardView() {
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
(removedWorktrees: Array<{ path: string; branch: string }>) => {
// Reset features that were assigned to the removed worktrees (by branch)
hookFeatures.forEach((feature) => {
const matchesRemovedWorktree = removedWorktrees.some((removed) => {
// Match by branch name since worktreePath is no longer stored
return feature.branchName === removed.branch;
});
if (matchesRemovedWorktree) {
// Reset the feature's branch assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
});
}
});
},
[hookFeatures, persistFeatureUpdate]
);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
@@ -293,13 +320,12 @@ export function BoardView() {
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path and branch) for filtering features
// Get current worktree info (path) 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(
() =>
@@ -309,8 +335,25 @@ export function BoardView() {
[currentProject, worktreesByProject]
);
// Get the branch for the currently selected worktree
// Find the worktree that matches the current selection, or use main worktree
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
// Primary worktree selected - find the main worktree
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
return worktrees.find(
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
);
}
}, [worktrees, currentWorktreePath]);
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
// 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
// Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
@@ -334,7 +377,7 @@ export function BoardView() {
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
handleDeleteAllVerified,
handleArchiveAllVerified,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -362,6 +405,205 @@ export function BoardView() {
currentWorktreeBranch,
});
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
useEffect(() => {
autoModeRunningRef.current = autoMode.isRunning;
}, [autoMode.isRunning]);
// Use a ref to track the latest features to avoid effect re-runs when features change
const hookFeaturesRef = useRef(hookFeatures);
useEffect(() => {
hookFeaturesRef.current = hookFeatures;
}, [hookFeatures]);
// Track features that are pending (started but not yet confirmed running)
const pendingFeaturesRef = useRef<Set<string>>(new Set());
// Listen to auto mode events to remove features from pending when they start running
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath =
"projectPath" in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case "auto_mode_feature_start":
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case "auto_mode_feature_complete":
case "auto_mode_error":
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
}
});
return unsubscribe;
}, [currentProject]);
useEffect(() => {
if (!autoMode.isRunning || !currentProject) {
return;
}
let isChecking = false;
let isActive = true; // Track if this effect is still active
const checkAndStartFeatures = async () => {
// Check if auto mode is still running and effect is still active
// Use ref to get the latest value, not the closure value
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Prevent concurrent executions
if (isChecking) {
return;
}
isChecking = true;
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Count currently running tasks + pending features
const currentRunning =
runningAutoTasks.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check
if (availableSlots <= 0) {
return;
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
if (f.status !== "backlog") return false;
const featureBranch = f.branchName;
// Features without branchName are considered unassigned (show only on primary worktree)
if (!featureBranch) {
// No branch assigned - show only when viewing primary worktree
const isViewingPrimary = currentWorktreePath === null;
return isViewingPrimary;
}
if (currentWorktreeBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// Show features assigned to primary worktree's branch
return currentProject.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
});
if (backlogFeatures.length === 0) {
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Filter out features with blocking dependencies if dependency blocking is enabled
const eligibleFeatures = enableDependencyBlocking
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
return blockingDeps.length === 0;
})
: sortedBacklog;
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
const primaryBranch =
(currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null) || "main";
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
}
// Final check before starting implementation
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Start the implementation - server will derive workDir from feature.branchName
const started = await handleStartImplementation(feature);
// If successfully started, track it as pending until we receive the start event
if (started) {
pendingFeaturesRef.current.add(feature.id);
}
}
} finally {
isChecking = false;
}
};
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 3000);
return () => {
// Mark as inactive to prevent any pending async operations from continuing
isActive = false;
clearInterval(interval);
// Clear pending features when effect unmounts or dependencies change
pendingFeaturesRef.current.clear();
};
}, [
autoMode.isRunning,
currentProject,
runningAutoTasks,
maxConcurrency,
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
currentWorktreeBranch,
currentWorktreePath,
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
persistFeatureUpdate,
handleStartImplementation,
]);
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -378,8 +620,6 @@ export function BoardView() {
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use column features hook
@@ -554,8 +794,13 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onStartAutoMode={() => autoMode.start()}
onStopAutoMode={() => autoMode.stop()}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
@@ -586,10 +831,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({
id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
@@ -646,7 +891,7 @@ export function BoardView() {
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
</div>
@@ -686,6 +931,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -698,6 +944,7 @@ export function BoardView() {
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -714,14 +961,14 @@ export function BoardView() {
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
{/* Delete All Verified Dialog */}
<DeleteAllVerifiedDialog
open={showDeleteAllVerifiedDialog}
onOpenChange={setShowDeleteAllVerifiedDialog}
{/* Archive All Verified Dialog */}
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
onOpenChange={setShowArchiveAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length}
onConfirm={async () => {
await handleDeleteAllVerified();
setShowDeleteAllVerifiedDialog(false);
await handleArchiveAllVerified();
setShowArchiveAllVerifiedDialog(false);
}}
/>
@@ -819,19 +1066,13 @@ export function BoardView() {
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree
// Reset features that were assigned to the deleted worktree (by branch)
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
// Match by branch name since worktreePath is no longer stored
if (feature.branchName === deletedWorktree.branch) {
// Reset the feature's branch assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
worktreePath: null as unknown as string | undefined,
});
}
});

View File

@@ -3,7 +3,9 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Play, StopCircle, Plus, Users } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Users } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
interface BoardHeaderProps {
@@ -11,8 +13,7 @@ interface BoardHeaderProps {
maxConcurrency: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onStartAutoMode: () => void;
onStopAutoMode: () => void;
onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
@@ -23,8 +24,7 @@ export function BoardHeader({
maxConcurrency,
onConcurrencyChange,
isAutoModeRunning,
onStartAutoMode,
onStopAutoMode,
onAutoModeToggle,
onAddFeature,
addFeatureShortcut,
isMounted,
@@ -63,29 +63,20 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<>
{isAutoModeRunning ? (
<Button
variant="destructive"
size="sm"
onClick={onStopAutoMode}
data-testid="stop-auto-mode"
>
<StopCircle className="w-4 h-4 mr-2" />
Stop Auto Mode
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={onStartAutoMode}
data-testid="start-auto-mode"
>
<Play className="w-4 h-4 mr-2" />
Auto Mode
</Button>
)}
</>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
Auto Mode
</Label>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
</div>
)}
<HotkeyButton

View File

@@ -261,7 +261,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.skipTests && !isCurrentAutoTask);
(feature.status === "in_progress" && !isCurrentAutoTask);
const {
attributes,
listeners,

View File

@@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
@@ -45,6 +44,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
@@ -66,7 +66,7 @@ interface AddFeatureDialogProps {
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
@@ -75,6 +75,7 @@ interface AddFeatureDialogProps {
branchSuggestions: string[];
defaultSkipTests: boolean;
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -88,10 +89,12 @@ export function AddFeatureDialog({
branchSuggestions,
defaultSkipTests,
defaultBranch = "main",
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
}: AddFeatureDialogProps) {
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
category: "",
description: "",
@@ -101,7 +104,7 @@ export function AddFeatureDialog({
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
branchName: "",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -124,8 +127,9 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch,
branchName: defaultBranch || "",
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
@@ -137,12 +141,25 @@ export function AddFeatureDialog({
return;
}
// Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error("Please select a branch name");
return;
}
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: newFeature.branchName || "";
onAdd({
category,
description: newFeature.description,
@@ -152,7 +169,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
branchName: finalBranchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
@@ -169,8 +186,9 @@ export function AddFeatureDialog({
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: defaultBranch,
branchName: "",
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
@@ -372,22 +390,17 @@ export function AddFeatureDialog({
/>
</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>
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value })
}
branchSuggestions={branchSuggestions}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
)}
{/* Priority Selector */}
@@ -501,6 +514,11 @@ export function AddFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={
useWorktrees &&
!useCurrentBranch &&
!newFeature.branchName.trim()
}
>
Add Feature
</HotkeyButton>

View File

@@ -0,0 +1,56 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Archive } from "lucide-react";
interface ArchiveAllVerifiedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
verifiedCount: number;
onConfirm: () => void;
}
export function ArchiveAllVerifiedDialog({
open,
onOpenChange,
verifiedCount,
onConfirm,
}: ArchiveAllVerifiedDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="archive-all-verified-dialog">
<DialogHeader>
<DialogTitle>Archive All Verified Features</DialogTitle>
<DialogDescription>
Are you sure you want to archive all verified features? They will be
moved to the archive box.
{verifiedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{verifiedCount} feature(s) will be archived.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
<Archive className="w-4 h-4 mr-2" />
Archive All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
@@ -49,18 +49,26 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(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
// Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
// This prevents showing stale PR URLs from previous worktrees
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
// Reset operation tracking
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
@@ -72,6 +80,7 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
}
}, [open, worktree?.path]);
@@ -98,6 +107,8 @@ export function CreatePRDialog({
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
// Mark operation as completed for refresh on close
operationCompletedRef.current = true;
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
@@ -105,7 +116,8 @@ export function CreatePRDialog({
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
onCreated();
// Don't call onCreated() here - keep dialog open to show success message
// onCreated() will be called when user closes the dialog
} else {
// Branch was pushed successfully
const prError = result.result.prError;
@@ -117,6 +129,8 @@ export function CreatePRDialog({
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed successfully
operationCompletedRef.current = true;
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
@@ -142,6 +156,8 @@ export function CreatePRDialog({
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
// Mark operation as completed - branch was pushed even though PR creation failed
operationCompletedRef.current = true;
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
@@ -182,19 +198,13 @@ export function CreatePRDialog({
};
const handleClose = () => {
// Only call onCreated() if an actual operation completed
// This prevents unnecessary refreshes when user cancels
if (operationCompletedRef.current) {
onCreated();
}
onOpenChange(false);
// Reset state after dialog closes
setTimeout(() => {
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
}, 200);
// State reset is handled by useEffect when open becomes false
};
if (!worktree) return null;
@@ -228,13 +238,18 @@ export function CreatePRDialog({
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 className="flex gap-2 justify-center">
<Button
onClick={() => window.open(prUrl, "_blank")}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</div>
</div>
) : shouldShowBrowserFallback ? (
<div className="py-6 text-center space-y-4">

View File

@@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
@@ -46,6 +45,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
@@ -69,7 +69,7 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
@@ -77,6 +77,7 @@ interface EditFeatureDialogProps {
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -89,12 +90,17 @@ export function EditFeatureDialog({
onUpdate,
categorySuggestions,
branchSuggestions,
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -114,6 +120,8 @@ export function EditFeatureDialog({
if (feature) {
setPlanningMode(feature.planningMode ?? 'skip');
setRequirePlanApproval(feature.requirePlanApproval ?? false);
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
@@ -123,6 +131,18 @@ export function EditFeatureDialog({
const handleUpdate = () => {
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
@@ -130,6 +150,13 @@ export function EditFeatureDialog({
? editingFeature.thinkingLevel ?? "none"
: "none";
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: editingFeature.branchName || "";
const updates = {
category: editingFeature.category,
description: editingFeature.description,
@@ -138,7 +165,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
@@ -351,33 +378,21 @@ export function EditFeatureDialog({
/>
</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>
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
testIdPrefix="edit-feature"
/>
)}
{/* Priority Selector */}
@@ -509,6 +524,12 @@ export function EditFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}
>
Save Changes
</HotkeyButton>

View File

@@ -1,7 +1,7 @@
export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-modal";
export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog";
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";

View File

@@ -77,67 +77,13 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
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]
);
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
const handleAddFeature = useCallback(
async (featureData: {
@@ -154,34 +100,24 @@ export function useBoardActions({
planningMode: PlanningMode;
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?.();
}
}
}
// Simplified: Only store branchName, no worktree creation on add
// Worktrees are created at execution time (when feature starts)
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees)
const finalBranchName = featureData.branchName || undefined;
const newFeatureData = {
...featureData,
status: "backlog" as const,
worktreePath,
branchName: finalBranchName,
// No worktreePath - derived at runtime from branchName
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
[addFeature, persistFeatureCreate, saveCategory]
);
const handleUpdateFeature = useCallback(
@@ -201,44 +137,12 @@ export function useBoardActions({
requirePlanApproval?: boolean;
}
) => {
// Get the current feature to check if branch is changing
const currentFeature = features.find((f) => f.id === featureId);
const currentBranch = currentFeature?.branchName || "main";
const newBranch = updates.branchName || "main";
const branchIsChanging = currentBranch !== newBranch;
const finalBranchName = updates.branchName || undefined;
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;
}
const finalUpdates = {
...updates,
branchName: finalBranchName,
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
@@ -247,7 +151,7 @@ export function useBoardActions({
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
);
const handleDeleteFeature = useCallback(
@@ -312,21 +216,18 @@ export function useBoardActions({
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;
// Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees,
featureWorktreePath || undefined
useWorktrees
// No worktreePath - server derives from feature.branchName
);
if (result.success) {
console.log(
"[Board] Feature run started successfully in worktree:",
featureWorktreePath || "main"
"[Board] Feature run started successfully, branch:",
feature.branchName || "default"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
@@ -355,10 +256,12 @@ export function useBoardActions({
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(", ");
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}`,
@@ -377,7 +280,14 @@ export function useBoardActions({
await handleRunFeature(feature);
return true;
},
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
[
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
]
);
const handleVerifyFeature = useCallback(
@@ -494,7 +404,6 @@ export function useBoardActions({
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
const prompt = followUpPrompt;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
@@ -526,15 +435,14 @@ export function useBoardActions({
});
const imagePaths = followUpImagePaths.map((img) => img.path);
// Use the feature's worktreePath to ensure work happens in the correct branch
const featureWorktreePath = followUpFeature.worktreePath;
// Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths,
featureWorktreePath
imagePaths
// No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
@@ -574,11 +482,11 @@ export function useBoardActions({
return;
}
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
// Server derives workDir from feature.branchName
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id,
feature.worktreePath
feature.id
// No worktreePath - server derives from feature.branchName
);
if (result.success) {
@@ -763,23 +671,25 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
const primaryBranch = projectPath
? getPrimaryWorktreeBranch(projectPath)
: null;
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";
// Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || primaryBranch || "main";
// If no worktree is selected (currentWorktreeBranch is null or main-like),
// show features with no branch or "main"/"master" branch
// If no worktree is selected (currentWorktreeBranch is null or matches primary),
// show features with no branch or primary branch
if (
!currentWorktreeBranch ||
currentWorktreeBranch === "main" ||
currentWorktreeBranch === "master"
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
featureBranch === "main" ||
featureBranch === "master"
(projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -799,57 +709,65 @@ export function useBoardActions({
}
if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
description:
currentWorktreeBranch &&
currentWorktreeBranch !== "main" &&
currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
description: !isOnPrimaryBranch
? `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)
);
// Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
const aBlocked = enableDependencyBlocking
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked = enableDependencyBlocking
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
if (!aBlocked && bBlocked) return -1;
// Within same blocked/unblocked group, sort by priority
return (a.priority || 999) - (b.priority || 999);
});
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
return getBlockingDependencies(f, features).length === 0;
});
if (!featureToStart) {
toast.info("No eligible features", {
description:
"All backlog features have unmet dependencies. Complete their dependencies first.",
});
return;
}
// Start only one feature per keypress (user must press again for next)
const featuresToStart = sortedBacklog.slice(0, 1);
for (const feature of featuresToStart) {
// 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,
});
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
await handleStartImplementation(featureToStart);
}, [
features,
runningAutoTasks,
handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
currentWorktreeBranch,
useWorktrees,
projectPath,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
]);
const handleDeleteAllVerified = useCallback(async () => {
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
for (const feature of verifiedFeatures) {
@@ -858,22 +776,29 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
console.error(
"[Board] Error stopping feature before archive:",
error
);
}
}
removeFeature(feature.id);
persistFeatureDelete(feature.id);
// Archive the feature by setting status to completed
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
toast.success("All verified features deleted", {
description: `Deleted ${verifiedFeatures.length} feature(s).`,
toast.success("All verified features archived", {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
removeFeature,
persistFeatureDelete,
updateFeature,
persistFeatureUpdate,
]);
return {
@@ -895,6 +820,6 @@ export function useBoardActions({
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
handleDeleteAllVerified,
handleArchiveAllVerified,
};
}

View File

@@ -1,7 +1,6 @@
import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store";
import { resolveDependencies } from "@/lib/dependency-resolver";
import { pathsEqual } from "@/lib/utils";
import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
type ColumnId = Feature["status"];
@@ -56,26 +55,24 @@ export function useBoardColumnFeatures({
// If feature has a running agent, always show it in "in_progress"
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;
// Check if feature matches the current worktree by branchName
// Features without branchName are considered unassigned (show only on primary worktree)
const featureBranch = 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);
if (!featureBranch) {
// No branch assigned - show only on primary worktree
const isViewingPrimary = currentWorktreePath === null;
matchesWorktree = isViewingPrimary;
} 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";
// Show features assigned to primary worktree's branch.
matchesWorktree = projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
} else {
// Has branchName but no worktreePath - match by branch name
// Match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
@@ -101,7 +98,9 @@ export function useBoardColumnFeatures({
}
} else {
// Unknown status, default to backlog
map.backlog.push(f);
if (matchesWorktree) {
map.backlog.push(f);
}
}
}
});
@@ -111,7 +110,29 @@ export function useBoardColumnFeatures({
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
map.backlog = orderedFeatures;
// Get all features to check blocking dependencies against
const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
// This keeps the dependency order within each group (unblocked/blocked)
if (enableDependencyBlocking) {
const unblocked: Feature[] = [];
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);
}
}
map.backlog = [...unblocked, ...blocked];
} else {
map.backlog = orderedFeatures;
}
}
return map;

View File

@@ -4,7 +4,6 @@ import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
import { getElectronAPI } from "@/lib/electron";
interface UseBoardDragDropProps {
features: Feature[];
@@ -15,8 +14,6 @@ interface UseBoardDragDropProps {
updates: Partial<Feature>
) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
projectPath: string | null; // Main project path
onWorktreeCreated?: () => void; // Callback when a new worktree is created
}
export function useBoardDragDrop({
@@ -25,66 +22,12 @@ export function useBoardDragDrop({
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
projectPath,
onWorktreeCreated,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature, useWorktrees } = useAppStore();
const { moveFeature } = 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]
);
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -118,17 +61,13 @@ export function useBoardDragDrop({
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - skipTests (non-TDD) items can be dragged between in_progress and verified
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
if (
draggedFeature.status !== "backlog" &&
draggedFeature.status !== "waiting_approval" &&
draggedFeature.status !== "verified"
) {
// Only allow dragging in_progress if it's a skipTests feature and not currently running
if (!draggedFeature.skipTests || isRunningTask) {
// - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if (draggedFeature.status === "in_progress") {
// Only allow dragging in_progress if it's not currently running
if (isRunningTask) {
console.log(
"[Board] Cannot drag feature - TDD feature or currently running"
"[Board] Cannot drag feature - currently running"
);
return;
}
@@ -154,23 +93,13 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
// From backlog
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
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -195,11 +124,10 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
worktreePath: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -208,13 +136,23 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between in_progress and verified
if (
} else if (draggedFeature.status === "in_progress") {
// Handle in_progress features being moved
if (targetStatus === "backlog") {
// Allow moving in_progress cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
targetStatus === "verified" &&
draggedFeature.status === "in_progress"
draggedFeature.skipTests
) {
// Manual verify via drag
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
@@ -223,7 +161,10 @@ export function useBoardDragDrop({
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
}
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between verified and waiting_approval
if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
@@ -237,10 +178,9 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog
// Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
// Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -263,8 +203,7 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
// Clear worktreePath when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -280,9 +219,6 @@ export function useBoardDragDrop({
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
getOrCreateWorktreeForFeature,
onWorktreeCreated,
useWorktrees,
]
);

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useAutoMode } from "@/hooks/use-auto-mode";
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
@@ -28,8 +27,6 @@ export function useBoardEffects({
isLoading,
setFeaturesWithContext,
}: UseBoardEffectsProps) {
const autoMode = useAutoMode();
// Make current project available globally for modal
useEffect(() => {
if (currentProject) {
@@ -101,8 +98,7 @@ export function useBoardEffects({
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
useAppStore.getState();
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
@@ -116,14 +112,6 @@ export function useBoardEffects({
addRunningTask(projectId, featureId);
});
}
const isAutoModeRunning =
status.autoLoopRunning ?? status.isRunning ?? false;
console.log(
"[Board] Syncing auto mode running state:",
isAutoModeRunning
);
setAutoModeRunning(projectId, isAutoModeRunning);
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);

View File

@@ -14,7 +14,7 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Trash2 } from "lucide-react";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { COLUMNS, ColumnId } from "./constants";
@@ -55,7 +55,7 @@ interface KanbanBoardProps {
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
onDeleteAllVerified: () => void;
onArchiveAllVerified: () => void;
}
export function KanbanBoard({
@@ -87,7 +87,7 @@ export function KanbanBoard({
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
onDeleteAllVerified,
onArchiveAllVerified,
}: KanbanBoardProps) {
return (
<div
@@ -119,12 +119,12 @@ export function KanbanBoard({
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onDeleteAllVerified}
data-testid="delete-all-verified-button"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Trash2 className="w-3 h-3 mr-1" />
Delete All
<Archive className="w-3 h-3 mr-1" />
Archive All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">

View File

@@ -0,0 +1,98 @@
"use client";
import { Label } from "@/components/ui/label";
import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface BranchSelectorProps {
useCurrentBranch: boolean;
onUseCurrentBranchChange: (useCurrent: boolean) => void;
branchName: string;
onBranchNameChange: (branchName: string) => void;
branchSuggestions: string[];
currentBranch?: string;
disabled?: boolean;
testIdPrefix?: string;
}
export function BranchSelector({
useCurrentBranch,
onUseCurrentBranchChange,
branchName,
onBranchNameChange,
branchSuggestions,
currentBranch,
disabled = false,
testIdPrefix = "branch",
}: BranchSelectorProps) {
// Validate: if "other branch" is selected, branch name is required
const isBranchRequired = !useCurrentBranch;
const hasError = isBranchRequired && !branchName.trim();
return (
<div className="space-y-2">
<Label id={`${testIdPrefix}-label`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
aria-labelledby={`${testIdPrefix}-label`}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={`${testIdPrefix}-current`} />
<Label
htmlFor={`${testIdPrefix}-current`}
className="font-normal cursor-pointer"
>
Use current selected branch
{currentBranch && (
<span className="text-muted-foreground ml-1">
({currentBranch})
</span>
)}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="other" id={`${testIdPrefix}-other`} />
<Label
htmlFor={`${testIdPrefix}-other`}
className="font-normal cursor-pointer"
>
Other branch
</Label>
</div>
</RadioGroup>
{!useCurrentBranch && (
<div className="ml-6 space-y-1">
<BranchAutocomplete
value={branchName}
onChange={onBranchNameChange}
branches={branchSuggestions}
placeholder="Select or create branch..."
data-testid={`${testIdPrefix}-input`}
disabled={disabled}
error={hasError}
/>
{hasError && (
<p className="text-xs text-destructive">
Branch name is required when "Other branch" is selected.
</p>
)}
</div>
)}
{disabled ? (
<p className="text-xs text-muted-foreground">
Branch cannot be changed after work has started.
</p>
) : (
<p className="text-xs text-muted-foreground">
{useCurrentBranch
? "Work will be done in the currently selected branch. A worktree will be created if needed."
: "Work will be done in this branch. A worktree will be created if needed."}
</p>
)}
</div>
);
}

View File

@@ -4,4 +4,5 @@ export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./branch-selector";
export * from "./planning-mode-selector";

View File

@@ -170,7 +170,8 @@ export function WorktreeActionsDropdown({
Commit Changes
</DropdownMenuItem>
)}
{(worktree.branch !== "main" || worktree.hasChanges) && (
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
{(!worktree.isMain || worktree.hasChanges) && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request

View File

@@ -1,47 +1,35 @@
"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);
}
// Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
return worktree.branch === feature.branchName;
}
// No branch assigned - belongs to main worktree
return worktree.isMain;
});
},
[runningFeatureIds, features, projectPath, getWorktreeKey]
[runningFeatureIds, features]
);
return {

View File

@@ -6,7 +6,7 @@ import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<void>;
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}

View File

@@ -9,9 +9,10 @@ import type { WorktreeInfo } from "../types";
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
@@ -34,8 +35,11 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
// Return removed worktrees so they can be handled by the caller
return result.removedWorktrees;
} catch (error) {
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
setIsLoading(false);
}
@@ -47,9 +51,13 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
fetchWorktrees().then((removedWorktrees) => {
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
});
}
}, [refreshTrigger, fetchWorktrees]);
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
useEffect(() => {
if (worktrees.length > 0) {
@@ -59,6 +67,8 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch);

View File

@@ -22,7 +22,6 @@ export interface DevServerInfo {
export interface FeatureInfo {
id: string;
worktreePath?: string;
branchName?: string;
}
@@ -33,6 +32,7 @@ export interface WorktreePanelProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
refreshTrigger?: number;

View File

@@ -21,6 +21,7 @@ export function WorktreePanel({
onCommit,
onCreatePR,
onCreateBranch,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
@@ -33,7 +34,7 @@ export function WorktreePanel({
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
} = useWorktrees({ projectPath, refreshTrigger });
} = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
const {
isStartingDevServer,
@@ -74,10 +75,8 @@ export function WorktreePanel({
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
projectPath,
runningFeatureIds,
features,
getWorktreeKey,
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
@@ -163,7 +162,12 @@ export function WorktreePanel({
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={fetchWorktrees}
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>

View File

@@ -212,16 +212,20 @@ export function ContextView() {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
// Only reload files on success
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
// Optionally show error toast to user here
} finally {
// Close dialog and reset state
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
}
};

View File

@@ -18,7 +18,6 @@ export function useAutoMode() {
setAutoModeRunning,
addRunningTask,
removeRunningTask,
clearRunningTasks,
currentProject,
addAutoModeActivity,
maxConcurrency,
@@ -30,7 +29,6 @@ export function useAutoMode() {
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
clearRunningTasks: state.clearRunningTasks,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
@@ -126,33 +124,6 @@ export function useAutoMode() {
}
break;
case "auto_mode_stopped":
// Auto mode was explicitly stopped (by user or error)
setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId);
console.log("[AutoMode] Auto mode stopped");
break;
case "auto_mode_started":
// Auto mode started - ensure UI reflects running state
console.log("[AutoMode] Auto mode started:", event.message);
break;
case "auto_mode_idle":
// Auto mode is running but has no pending features to pick up
// This is NOT a stop - auto mode keeps running and will pick up new features
console.log("[AutoMode] Auto mode idle - waiting for new features");
break;
case "auto_mode_complete":
// Legacy event - only handle if it looks like a stop (for backwards compatibility)
if (event.message === "Auto mode stopped") {
setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId);
console.log("[AutoMode] Auto mode stopped (legacy event)");
}
break;
case "auto_mode_error":
if (event.featureId && event.error) {
// Check if this is a user-initiated cancellation or abort (not a real error)
@@ -356,130 +327,36 @@ export function useAutoMode() {
projectId,
addRunningTask,
removeRunningTask,
clearRunningTasks,
setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
currentProject?.path,
]);
// Restore auto mode for all projects that were running when app was closed
// This runs once on mount to restart auto loops for persisted running states
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
// Find all projects that have auto mode marked as running
const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
[];
for (const [projectId, state] of Object.entries(autoModeByProject)) {
if (state.isRunning) {
// Find the project path for this project ID
const project = projects.find((p) => p.id === projectId);
if (project) {
projectsToRestart.push({ projectId, projectPath: project.path });
}
}
}
// Restart auto mode for each project
for (const { projectId, projectPath } of projectsToRestart) {
console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
api.autoMode
.start(projectPath, maxConcurrency)
.then((result) => {
if (!result.success) {
console.error(
`[AutoMode] Failed to restore auto mode for ${projectPath}:`,
result.error
);
// Mark as not running if we couldn't restart
setAutoModeRunning(projectId, false);
} else {
console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
}
})
.catch((error) => {
console.error(
`[AutoMode] Error restoring auto mode for ${projectPath}:`,
error
);
setAutoModeRunning(projectId, false);
});
}
// Only run once on mount - intentionally empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Start auto mode
const start = useCallback(async () => {
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
const start = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.start(
currentProject.path,
maxConcurrency
);
if (result.success) {
setAutoModeRunning(currentProject.id, true);
console.log(
`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
);
} else {
console.error("[AutoMode] Failed to start:", result.error);
throw new Error(result.error || "Failed to start auto mode");
}
} catch (error) {
console.error("[AutoMode] Error starting:", error);
if (currentProject) {
setAutoModeRunning(currentProject.id, false);
}
throw error;
}
setAutoModeRunning(currentProject.id, true);
console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
}, [currentProject, setAutoModeRunning, maxConcurrency]);
// Stop auto mode - only turns off the toggle, running tasks continue
const stop = useCallback(async () => {
// Stop auto mode - UI only, running tasks continue until natural completion
const stop = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
throw new Error("Auto mode API not available");
}
const result = await api.autoMode.stop(currentProject.path);
if (result.success) {
setAutoModeRunning(currentProject.id, false);
// NOTE: We intentionally do NOT clear running tasks here.
// Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event.
console.log(
"[AutoMode] Stopped successfully - running tasks will continue"
);
} else {
console.error("[AutoMode] Failed to stop:", result.error);
throw new Error(result.error || "Failed to stop auto mode");
}
} catch (error) {
console.error("[AutoMode] Error stopping:", error);
throw error;
}
setAutoModeRunning(currentProject.id, false);
// NOTE: We intentionally do NOT clear running tasks here.
// Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event.
console.log("[AutoMode] Stopped - running tasks will continue");
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature

View File

@@ -80,7 +80,6 @@ export interface RunningAgentsResult {
success: boolean;
runningAgents?: RunningAgent[];
totalCount?: number;
autoLoopRunning?: boolean;
error?: string;
}
@@ -217,7 +216,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
isRunning?: boolean;
autoLoopRunning?: boolean; // Backend uses this name instead of isRunning
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
@@ -1449,7 +1447,6 @@ function createMockAutoModeAPI(): AutoModeAPI {
return {
success: true,
isRunning: mockAutoModeRunning,
autoLoopRunning: mockAutoModeRunning,
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
runningFeatures: Array.from(mockRunningFeatures),
runningCount: mockRunningFeatures.size,
@@ -2617,7 +2614,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning: mockAutoModeRunning,
};
},
};

View File

@@ -752,7 +752,6 @@ export class HttpApiClient implements ElectronAPI {
isAutoMode: boolean;
}>;
totalCount?: number;
autoLoopRunning?: boolean;
error?: string;
}> => this.get("/api/running-agents"),
};

View File

@@ -299,9 +299,8 @@ export interface Feature {
error?: string; // Error message if the agent errored during processing
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
dependencies?: string[]; // Array of feature IDs this feature depends on
// Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
@@ -433,7 +432,10 @@ export interface AppState {
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
currentWorktreeByProject: Record<
string,
{ path: string | null; branch: string }
>;
worktreesByProject: Record<
string,
Array<{
@@ -629,7 +631,11 @@ export interface AppActions {
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
setCurrentWorktree: (
projectPath: string,
worktreePath: string | null,
branch: string
) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
@@ -640,7 +646,9 @@ export interface AppActions {
changedFilesCount?: number;
}>
) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
getCurrentWorktree: (
projectPath: string
) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
@@ -648,6 +656,8 @@ export interface AppActions {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
@@ -1402,7 +1412,8 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
setEnableDependencyBlocking: (enabled) =>
set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1435,6 +1446,18 @@ export const useAppStore = create<AppState & AppActions>()(
return get().worktreesByProject[projectPath] ?? [];
},
isPrimaryWorktreeBranch: (projectPath, branchName) => {
const worktrees = get().worktreesByProject[projectPath] ?? [];
const primary = worktrees.find((w) => w.isMain);
return primary?.branch === branchName;
},
getPrimaryWorktreeBranch: (projectPath) => {
const worktrees = get().worktreesByProject[projectPath] ?? [];
const primary = worktrees.find((w) => w.isMain);
return primary?.branch ?? null;
},
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2298,7 +2321,8 @@ export const useAppStore = create<AppState & AppActions>()(
// Settings
apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
// Note: autoModeByProject is intentionally NOT persisted
// Auto-mode should always default to OFF on app refresh
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,

View File

@@ -198,30 +198,6 @@ export type AutoModeEvent =
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_complete";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_stopped";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_started";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_idle";
message: string;
projectId?: string;
projectPath?: string;
}
| {
type: "auto_mode_phase";
featureId: string;
@@ -375,20 +351,6 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
start: (
projectPath: string,
maxConcurrency?: number
) => Promise<{
success: boolean;
error?: string;
}>;
stop: (projectPath: string) => Promise<{
success: boolean;
error?: string;
runningFeatures?: number;
}>;
stopFeature: (featureId: string) => Promise<{
success: boolean;
error?: string;
@@ -396,7 +358,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
autoLoopRunning?: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
@@ -408,8 +369,7 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean,
worktreePath?: string
useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -455,7 +415,7 @@ export interface AutoModeAPI {
featureId: string,
prompt: string,
imagePaths?: string[],
worktreePath?: string
useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -708,6 +668,10 @@ export interface WorktreeAPI {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
removedWorktrees?: Array<{
path: string;
branch: string;
}>;
error?: string;
}>;

View File

@@ -97,7 +97,7 @@ export const TEST_IDS = {
addFeatureButton: "add-feature-button",
addFeatureDialog: "add-feature-dialog",
confirmAddFeature: "confirm-add-feature",
featureBranchInput: "feature-branch-input",
featureBranchInput: "feature-input",
featureCategoryInput: "feature-category-input",
worktreeSelector: "worktree-selector",

View File

@@ -120,7 +120,9 @@ export async function getDragHandleForFeature(
*/
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 });
await page.waitForSelector('[data-testid="add-feature-dialog"]', {
timeout: 5000,
});
}
/**
@@ -132,17 +134,30 @@ export async function fillAddFeatureDialog(
options?: { branch?: string; category?: string }
): Promise<void> {
// Fill description (using the dropzone textarea)
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
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();
// First, select "Other branch" radio option if not already selected
const otherBranchRadio = page
.locator('[data-testid="feature-radio-group"]')
.locator('[id="feature-other"]');
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
await otherBranchRadio.click();
// Wait for the branch input to appear
await page.waitForTimeout(300);
// Now click on the branch input (autocomplete)
const branchInput = page.locator('[data-testid="feature-input"]');
await branchInput.waitFor({ state: "visible", timeout: 5000 });
await branchInput.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
const commandInput = page.locator('[cmdk-input]');
const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
@@ -152,10 +167,12 @@ export async function fillAddFeatureDialog(
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
const categoryButton = page.locator('[data-testid="feature-category-input"]');
const categoryButton = page.locator(
'[data-testid="feature-category-input"]'
);
await categoryButton.click();
await page.waitForTimeout(300);
const commandInput = page.locator('[cmdk-input]');
const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.category);
await commandInput.press("Enter");
await page.waitForTimeout(200);
@@ -201,8 +218,13 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
/**
* 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") });
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
}
@@ -210,9 +232,13 @@ export async function selectWorktreeBranch(page: Page, branchName: string): Prom
/**
* Get the currently selected branch in the worktree selector
*/
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
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 selectedButton = page.locator(
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
);
const text = await selectedButton.textContent().catch(() => null);
return text?.trim() || null;
}
@@ -220,7 +246,12 @@ export async function getSelectedWorktreeBranch(page: Page): Promise<string | nu
/**
* 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") });
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);
}

View File

@@ -741,14 +741,15 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Create a worktree first
// Note: Worktrees are created at execution time (when feature starts),
// not when adding to backlog. We can specify a branch name without
// creating a worktree first.
const branchName = "feature/test-branch";
await apiCreateWorktree(page, testRepo.path, branchName);
// Click add feature button
await clickAddFeature(page);
// Fill in the feature details
// Fill in the feature details with a branch name
await fillAddFeatureDialog(page, "Test feature for worktree", {
branch: branchName,
category: "Testing",
@@ -773,9 +774,12 @@ test.describe("Worktree Integration Tests", () => {
expect(featureData.description).toBe("Test feature for worktree");
expect(featureData.branchName).toBe(branchName);
expect(featureData.status).toBe("backlog");
// Verify worktreePath is not set when adding to backlog
// (worktrees are created at execution time, not when adding to backlog)
expect(featureData.worktreePath).toBeUndefined();
});
test("should create worktree automatically when adding feature with new branch", async ({
test("should store branch name when adding feature with new branch (worktree created at execution)", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
@@ -783,12 +787,13 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Use a branch name that doesn't exist yet - NO worktree is pre-created
// Use a branch name that doesn't exist yet
// Note: Worktrees are now created at execution time, not when adding to backlog
const branchName = "feature/auto-create-worktree";
const expectedWorktreePath = getWorktreePath(testRepo.path, branchName);
// Verify worktree does NOT exist before we create the feature
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
// Verify branch does NOT exist before we create the feature
const branchesBefore = await listBranches(testRepo.path);
expect(branchesBefore).not.toContain(branchName);
// Click add feature button
await clickAddFeature(page);
@@ -802,17 +807,14 @@ test.describe("Worktree Integration Tests", () => {
// Confirm
await confirmAddFeature(page);
// Wait for the worktree to be created
await page.waitForTimeout(2000);
// Wait for feature to be saved
await page.waitForTimeout(1000);
// Verify worktree was automatically created when feature was added
expect(fs.existsSync(expectedWorktreePath)).toBe(true);
// Verify branch was NOT created when adding feature (created at execution time)
const branchesAfter = await listBranches(testRepo.path);
expect(branchesAfter).not.toContain(branchName);
// Verify the branch was created
const branches = await listBranches(testRepo.path);
expect(branches).toContain(branchName);
// Verify feature was created with correct branch
// Verify feature was created with correct branch name stored
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
expect(featureDirs.length).toBeGreaterThan(0);
@@ -829,8 +831,15 @@ test.describe("Worktree Integration Tests", () => {
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
// Verify branch name is stored
expect(featureData.branchName).toBe(branchName);
expect(featureData.worktreePath).toBe(expectedWorktreePath);
// Verify worktreePath is NOT set (worktrees are created at execution time)
expect(featureData.worktreePath).toBeUndefined();
// Verify feature is in backlog status
expect(featureData.status).toBe("backlog");
});
test("should reset feature branch and worktree when worktree is deleted", async ({
@@ -887,8 +896,11 @@ test.describe("Worktree Integration Tests", () => {
let featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
// Verify feature was created with the branch name stored
expect(featureData.branchName).toBe(branchName);
expect(featureData.worktreePath).toBe(worktreePath);
// Verify worktreePath is NOT set (worktrees are created at execution time, not when adding)
expect(featureData.worktreePath).toBeUndefined();
// Delete the worktree via UI
// Open the worktree actions menu
@@ -911,10 +923,11 @@ test.describe("Worktree Integration Tests", () => {
// Verify worktree is deleted
expect(fs.existsSync(worktreePath)).toBe(false);
// Verify feature's branchName and worktreePath are reset to null
// Verify feature's branchName is reset to null/undefined when worktree is deleted
// (worktreePath was never stored, so it remains undefined)
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBeNull();
expect(featureData.worktreePath).toBeNull();
expect(featureData.worktreePath).toBeUndefined();
// Verify the feature appears in the backlog when main is selected
const mainButton = page.getByRole("button", { name: "main" }).first();
@@ -940,8 +953,9 @@ test.describe("Worktree Integration Tests", () => {
await otherWorktreeButton.click();
await page.waitForTimeout(500);
// Unassigned features should still be visible in the backlog
await expect(featureText).toBeVisible({ timeout: 5000 });
// Unassigned features should NOT be visible on non-primary worktrees
// They should only show on the primary (main) worktree
await expect(featureText).not.toBeVisible({ timeout: 5000 });
});
test("should filter features by selected worktree", async ({ page }) => {
@@ -1062,9 +1076,11 @@ test.describe("Worktree Integration Tests", () => {
// Open add feature dialog
await clickAddFeature(page);
// Verify the branch input button shows the selected worktree's branch
const branchButton = page.locator('[data-testid="feature-branch-input"]');
await expect(branchButton).toContainText(branchName, { timeout: 5000 });
// Verify the branch selector shows the selected worktree's branch
// When a worktree is selected, "Use current selected branch" should be selected
// and the branch name should be shown in the label
const currentBranchLabel = page.locator('label[for="feature-current"]');
await expect(currentBranchLabel).toContainText(branchName, { timeout: 5000 });
// Close dialog
await page.keyboard.press("Escape");
@@ -2337,7 +2353,7 @@ test.describe("Worktree Integration Tests", () => {
// Edit Feature with Branch Change
// ==========================================================================
test("should create worktree when editing a feature and selecting a new branch", async ({
test("should update branchName when editing a feature and selecting a new branch", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
@@ -2383,7 +2399,7 @@ test.describe("Worktree Integration Tests", () => {
const newBranchName = "feature/edited-branch";
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
// Verify worktree does NOT exist before editing
// Verify worktree does NOT exist before editing (worktrees are created at execution time)
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
// Find and click the edit button on the feature card
@@ -2398,8 +2414,12 @@ test.describe("Worktree Integration Tests", () => {
const editDialog = page.locator('[data-testid="edit-feature-dialog"]');
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Select "Other branch" to enable the branch input
const otherBranchRadio = page.locator('label[for="edit-feature-other"]');
await otherBranchRadio.click();
// Find and click on the branch input to open the autocomplete
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2415,20 +2435,22 @@ test.describe("Worktree Integration Tests", () => {
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
await saveButton.click();
// Wait for the dialog to close and worktree to be created
// Wait for the dialog to close
await page.waitForTimeout(2000);
// Verify worktree was automatically created
expect(fs.existsSync(expectedWorktreePath)).toBe(true);
// Verify worktree was NOT created during editing (worktrees are created at execution time)
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
// Verify the branch was created
// Verify branch was NOT created (created at execution time)
const branches = await listBranches(testRepo.path);
expect(branches).toContain(newBranchName);
expect(branches).not.toContain(newBranchName);
// Verify feature was updated with correct branch and worktreePath
// Verify feature was updated with correct branchName only
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBe(newBranchName);
expect(featureData.worktreePath).toBe(expectedWorktreePath);
// worktreePath should not exist in the feature data
expect(featureData.worktreePath).toBeUndefined();
});
test("should not create worktree when editing a feature and selecting main branch", async ({
@@ -2491,7 +2513,7 @@ test.describe("Worktree Integration Tests", () => {
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Find and click on the branch input
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2550,7 +2572,7 @@ test.describe("Worktree Integration Tests", () => {
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Change to the existing branch
const branchInput = page.locator('[data-testid="edit-feature-branch"]');
const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2571,7 +2593,8 @@ test.describe("Worktree Integration Tests", () => {
);
expect(matchingWorktrees.length).toBe(1);
// Verify feature was updated with the correct worktreePath
// Verify feature was updated with the correct branchName
// Note: worktreePath is no longer stored - worktrees are created server-side at execution time
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
@@ -2586,6 +2609,7 @@ test.describe("Worktree Integration Tests", () => {
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBe(existingBranch);
expect(featureData.worktreePath).toBe(existingWorktreePath);
// worktreePath should not exist in the feature data (worktrees are created at execution time)
expect(featureData.worktreePath).toBeUndefined();
});
});

View File

@@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}

View File

@@ -6,8 +6,6 @@
import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js";
import { createStartHandler } from "./routes/start.js";
import { createStopHandler } from "./routes/stop.js";
import { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js";
@@ -22,8 +20,6 @@ import { createApprovePlanHandler } from "./routes/approve-plan.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
router.post("/start", createStartHandler(autoModeService));
router.post("/stop", createStopHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));

View File

@@ -12,13 +12,14 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
worktreePath?: string;
};
const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
req.body as {
projectPath: string;
featureId: string;
prompt: string;
imagePaths?: string[];
useWorktrees?: boolean;
};
if (!projectPath || !featureId || !prompt) {
res.status(400).json({
@@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return;
}
// Start follow-up in background, using the feature's worktreePath for correct branch
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
.followUpFeature(
projectPath,
featureId,
prompt,
imagePaths,
useWorktrees ?? true
)
.catch((error) => {
logger.error(
`[AutoMode] Follow up feature ${featureId} error:`,
error
);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
res.status(400).json({
success: false,
error: "projectPath and featureId are required",
});
return;
}

View File

@@ -12,30 +12,30 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
worktreePath?: string;
};
if (!projectPath || !featureId) {
res
.status(400)
.json({
success: false,
error: "projectPath and featureId are required",
});
res.status(400).json({
success: false,
error: "projectPath and featureId are required",
});
return;
}
// 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
// executeFeature derives workDir from feature.branchName
autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath)
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -1,31 +0,0 @@
/**
* POST /start endpoint - Start auto mode loop
*/
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, maxConcurrency } = req.body as {
projectPath: string;
maxConcurrency?: number;
};
if (!projectPath) {
res
.status(400)
.json({ success: false, error: "projectPath is required" });
return;
}
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
res.json({ success: true });
} catch (error) {
logError(error, "Start auto loop failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,19 +0,0 @@
/**
* POST /stop endpoint - Stop auto mode loop
*/
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const runningCount = await autoModeService.stopAutoLoop();
res.json({ success: true, runningFeatures: runningCount });
} catch (error) {
logError(error, "Stop auto loop failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) {
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning: status.autoLoopRunning,
});
} catch (error) {
logError(error, "Get running agents failed");

View File

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

View File

@@ -8,6 +8,7 @@
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync } from "fs";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
const execAsync = promisify(exec);
@@ -58,10 +59,12 @@ export function createListHandler() {
});
const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
let current: { path?: string; branch?: string } = {};
let isFirst = true;
// First pass: detect removed worktrees
for (const line of lines) {
if (line.startsWith("worktree ")) {
current.path = normalizePath(line.slice(9));
@@ -69,19 +72,40 @@ export function createListHandler() {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
if (current.path && current.branch) {
worktrees.push({
path: current.path,
branch: current.branch,
isMain: isFirst,
isCurrent: current.branch === currentBranch,
hasWorktree: true,
});
isFirst = false;
const isMainWorktree = isFirst;
// Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself)
if (!isMainWorktree && !existsSync(current.path)) {
// Worktree directory doesn't exist - it was manually deleted
removedWorktrees.push({
path: current.path,
branch: current.branch,
});
} else {
// Worktree exists (or is main worktree), add it to the list
worktrees.push({
path: current.path,
branch: current.branch,
isMain: isMainWorktree,
isCurrent: current.branch === currentBranch,
hasWorktree: true,
});
isFirst = false;
}
}
current = {};
}
}
// Prune removed worktrees from git (only if any were detected)
if (removedWorktrees.length > 0) {
try {
await execAsync("git worktree prune", { cwd: projectPath });
} catch {
// Prune failed, but we'll still report the removed worktrees
}
}
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
@@ -103,7 +127,11 @@ export function createListHandler() {
}
}
res.json({ success: true, worktrees });
res.json({
success: true,
worktrees,
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
});
} catch (error) {
logError(error, "List worktrees failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -19,15 +19,11 @@ import type { EventEmitter } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { classifyError } from "../lib/error-handler.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import {
getFeatureDir,
getFeaturesDir,
getAutomakerDir,
getWorktreesDir,
} from "../lib/automaker-paths.js";
import { FeatureLoader } from "./feature-loader.js";
import { getFeatureDir, getAutomakerDir, getFeaturesDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
@@ -313,10 +309,11 @@ interface RunningFeature {
startTime: number;
}
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
interface AutoLoopState {
projectPath: string;
maxConcurrency: number;
abortController: AbortController;
isRunning: boolean;
}
interface PendingApproval {
@@ -326,9 +323,17 @@ interface PendingApproval {
projectPath: string;
}
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>();
private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
@@ -452,7 +457,6 @@ export class AutoModeService {
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
* @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
@@ -465,74 +469,88 @@ export class AutoModeService {
}
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
throw new Error("already running");
}
// Add to running features immediately to prevent race conditions
const abortController = new AbortController();
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Use provided worktree path if given, otherwise setup new worktree if enabled
if (providedWorktreePath) {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
try {
// Resolve relative paths relative to projectPath, absolute paths as-is
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
// Verify the path exists before using it
await fs.access(resolvedPath);
worktreePath = resolvedPath;
console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
} catch (error) {
console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
// Fall through to create new worktree or use project path
}
}
if (!worktreePath && useWorktrees) {
// No specific worktree provided, create a new one for this feature
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
this.runningFeatures.set(featureId, {
const tempRunningFeature: RunningFeature = {
featureId,
projectPath,
worktreePath,
branchName,
worktreePath: null,
branchName: null,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Emit feature start event
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: {
id: featureId,
title: "Loading...",
description: "Feature is starting",
},
});
};
this.runningFeatures.set(featureId, tempRunningFeature);
try {
// Load feature details
// Check if feature has existing context - if so, resume instead of starting fresh
const hasExistingContext = await this.contextExists(
projectPath,
featureId
);
if (hasExistingContext) {
console.log(
`[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
);
// Remove from running features temporarily, resumeFeature will add it back
this.runningFeatures.delete(featureId);
return this.resumeFeature(projectPath, featureId, useWorktrees);
}
// Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: {
id: featureId,
title: "Loading...",
description: "Feature is starting",
},
});
// Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName
// If no branchName, derive from feature ID: feature/{featureId}
let worktreePath: string | null = null;
const branchName = feature.branchName || `feature/${featureId}`;
if (useWorktrees && branchName) {
// Try to find existing worktree for this branch
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (!worktreePath) {
// Create worktree for this branch
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
console.log(
`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
);
}
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath
? path.resolve(worktreePath)
: path.resolve(projectPath);
// Update running feature with actual worktree info
tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName;
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -567,7 +585,7 @@ export class AutoModeService {
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
`[AutoMode] Executing feature ${featureId} with model: ${model}`
`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with the feature's model and images
@@ -576,6 +594,7 @@ export class AutoModeService {
featureId,
prompt,
abortController,
projectPath,
imagePaths,
model,
{
@@ -596,7 +615,7 @@ export class AutoModeService {
featureId,
passes: true,
message: `Feature completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
(Date.now() - tempRunningFeature.startTime) / 1000
)}s`,
projectPath,
});
@@ -651,6 +670,10 @@ export class AutoModeService {
featureId: string,
useWorktrees = false
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error("already running");
}
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -674,7 +697,9 @@ export class AutoModeService {
);
}
// No context, start fresh
// No context, start fresh - executeFeature will handle adding to runningFeatures
// Remove the temporary entry we added
this.runningFeatures.delete(featureId);
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
@@ -686,7 +711,7 @@ export class AutoModeService {
featureId: string,
prompt: string,
imagePaths?: string[],
providedWorktreePath?: string
useWorktrees = true
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -694,32 +719,30 @@ export class AutoModeService {
const abortController = new AbortController();
// Use the provided worktreePath (from the feature's assigned branch)
// Fall back to project path if not provided
// Load feature info for context FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
// Derive workDir from feature.branchName
// If no branchName, derive from feature ID: feature/{featureId}
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
const branchName = feature?.branchName || `feature/${featureId}`;
if (providedWorktreePath) {
try {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
await fs.access(resolvedPath);
workDir = resolvedPath;
worktreePath = resolvedPath;
} catch {
// Worktree path provided but doesn't exist, use project path
console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
if (useWorktrees && branchName) {
// Try to find existing worktree for this branch
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (worktreePath) {
workDir = worktreePath;
console.log(
`[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`
);
}
}
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -756,7 +779,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
projectPath,
worktreePath,
branchName: worktreePath ? path.basename(worktreePath) : null,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -853,6 +876,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
fullPrompt,
abortController,
projectPath,
allImagePaths.length > 0 ? allImagePaths : imagePaths,
model,
{
@@ -975,17 +999,25 @@ Address the follow-up instructions above. Review the previous work and make the
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
console.log(
`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`
);
}
} else {
// Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
const legacyWorktreePath = path.join(
projectPath,
".worktrees",
featureId
);
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
console.log(
`[AutoMode] No worktree found, committing in project path: ${workDir}`
);
}
}
@@ -1135,18 +1167,17 @@ Format your response as a structured markdown document.`;
}
}
/**
* Get current status
*/
getStatus(): {
isRunning: boolean;
autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
autoLoopRunning: this.autoLoopRunning,
isRunning: this.runningFeatures.size > 0,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
@@ -1323,6 +1354,7 @@ Format your response as a structured markdown document.`;
// Private helpers
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
@@ -1381,10 +1413,15 @@ Format your response as a structured markdown document.`;
branchName: string
): Promise<string> {
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
const existingWorktree = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
console.log(
`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
);
return existingWorktree;
}
@@ -1683,6 +1720,7 @@ This helps parse your summary correctly in the output logs.`;
featureId: string,
prompt: string,
abortController: AbortController,
projectPath: string,
imagePaths?: string[],
model?: string,
options?: {
@@ -1692,7 +1730,7 @@ This helps parse your summary correctly in the output logs.`;
previousContent?: string;
}
): Promise<void> {
const projectPath = options?.projectPath || workDir;
const finalProjectPath = options?.projectPath || projectPath;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
@@ -1708,7 +1746,9 @@ This helps parse your summary correctly in the output logs.`;
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") {
console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
console.log(
`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`
);
// Simulate some work being done
await this.sleep(500);
@@ -1740,8 +1780,7 @@ This helps parse your summary correctly in the output logs.`;
await this.sleep(200);
// Save mock agent output
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
@@ -1759,7 +1798,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`);
console.log(
`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`
);
return;
}
@@ -1812,10 +1853,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
let specDetected = false;
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
// Note: We use projectPath here, not workDir, because workDir might be a worktree path
const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
@@ -1829,7 +1868,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
console.error(
`[AutoMode] Failed to write agent output for ${featureId}:`,
error
);
}
};
@@ -1848,11 +1890,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
if (responseText.endsWith('\n')) {
responseText += '\n';
if (responseText.length > 0 && !responseText.endsWith("\n\n")) {
if (responseText.endsWith("\n")) {
responseText += "\n";
} else {
responseText += '\n\n';
responseText += "\n\n";
}
}
responseText += block.text || "";
@@ -2275,12 +2317,16 @@ Implement all the changes described in the plan above.`;
});
// Also add to file output for persistence
if (responseText.length > 0 && !responseText.endsWith('\n')) {
responseText += '\n';
if (responseText.length > 0 && !responseText.endsWith("\n")) {
responseText += "\n";
}
responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) {
responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
responseText += `Input: ${JSON.stringify(
block.input,
null,
2
)}\n`;
}
scheduleWrite();
}
@@ -2420,7 +2466,28 @@ Begin implementing task ${task.id} now.`;
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, ms);
// If signal is provided and already aborted, reject immediately
if (signal?.aborted) {
clearTimeout(timeout);
reject(new Error("Aborted"));
return;
}
// Listen for abort signal
if (signal) {
signal.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Aborted"));
},
{ once: true }
);
}
});
}
}

View File

@@ -24,6 +24,8 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';

View File

@@ -324,9 +324,9 @@ describe("auto-mode-service.ts - Planning Mode", () => {
describe("status management", () => {
it("should report correct status", () => {
const status = service.getStatus();
expect(status.autoLoopRunning).toBe(false);
expect(status.runningFeatures).toEqual([]);
expect(status.isRunning).toBe(false);
expect(status.runningCount).toBe(0);
});
});
});

View File

@@ -582,3 +582,4 @@ The route organization pattern provides:
Apply this pattern to all route modules for consistency and improved code quality.

169
package-lock.json generated
View File

@@ -33,9 +33,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
@@ -1765,57 +1767,6 @@
}
}
},
"apps/app/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"license": "MIT",
@@ -10909,6 +10860,30 @@
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
@@ -10932,6 +10907,69 @@
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -10993,6 +11031,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",