feat: enhance UI components and branch management

- Added new RadioGroup and Switch components for better UI interaction.
- Introduced BranchSelector for improved branch selection in feature dialogs.
- Updated Autocomplete and BranchAutocomplete components to handle error states.
- Refactored feature management to archive verified features instead of deleting them.
- Enhanced worktree handling by removing worktreePath from features, relying on branchName instead.
- Improved auto mode functionality by integrating branch management and worktree updates.
- Cleaned up unused code and optimized existing logic for better performance.
This commit is contained in:
Cody Seibert
2025-12-17 22:29:39 -05:00
parent cffdec91f1
commit 0549b8085a
45 changed files with 1669 additions and 1346 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
"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,30 @@
"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"; "use client";
import { useEffect, useState, useCallback, useMemo } from "react"; import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { import {
PointerSensor, PointerSensor,
useSensor, useSensor,
@@ -10,7 +10,9 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store"; import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron"; import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils"; import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode"; import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -25,7 +27,7 @@ import {
AddFeatureDialog, AddFeatureDialog,
AgentOutputModal, AgentOutputModal,
CompletedFeaturesModal, CompletedFeaturesModal,
DeleteAllVerifiedDialog, ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog, DeleteCompletedFeatureDialog,
EditFeatureDialog, EditFeatureDialog,
FeatureSuggestionsDialog, FeatureSuggestionsDialog,
@@ -72,6 +74,10 @@ export function BoardView() {
setCurrentWorktree, setCurrentWorktree,
getWorktrees, getWorktrees,
setWorktrees, setWorktrees,
useWorktrees,
enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore(); } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const { const {
@@ -89,7 +95,7 @@ export function BoardView() {
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>( const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set() new Set()
); );
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false); useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] = const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false); useState(false);
@@ -277,6 +283,27 @@ export function BoardView() {
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject }); 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) // Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => { const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => { return hookFeatures.filter((f) => {
@@ -285,13 +312,12 @@ export function BoardView() {
}); });
}, [hookFeatures, runningAutoTasks]); }, [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 // This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path) ? getCurrentWorktree(currentProject.path)
: null; : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null; const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo( const worktrees = useMemo(
() => () =>
@@ -301,8 +327,25 @@ export function BoardView() {
[currentProject, worktreesByProject] [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) // 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 = const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main"; currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
@@ -326,7 +369,7 @@ export function BoardView() {
handleOutputModalNumberKeyPress, handleOutputModalNumberKeyPress,
handleForceStopFeature, handleForceStopFeature,
handleStartNextFeatures, handleStartNextFeatures,
handleDeleteAllVerified, handleArchiveAllVerified,
} = useBoardActions({ } = useBoardActions({
currentProject, currentProject,
features: hookFeatures, features: hookFeatures,
@@ -354,6 +397,202 @@ export function BoardView() {
currentWorktreeBranch, 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]);
// 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
const primaryBranch = currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null;
const backlogFeatures = hookFeatures.filter((f) => {
if (f.status !== "backlog") return false;
// 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 matches primary),
// show features with no branch or primary branch
if (
!currentWorktreeBranch ||
(currentProject.path &&
isPrimaryWorktreeBranch(
currentProject.path,
currentWorktreeBranch
))
) {
return (
!f.branchName ||
(currentProject.path &&
isPrimaryWorktreeBranch(currentProject.path, featureBranch))
);
}
// Otherwise, only show features matching the selected worktree branch
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, hookFeatures);
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,
currentWorktreeBranch,
currentWorktreePath,
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
persistFeatureUpdate,
handleStartImplementation,
]);
// Use keyboard shortcuts hook (after actions hook) // Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({ useBoardKeyboardShortcuts({
features: hookFeatures, features: hookFeatures,
@@ -422,8 +661,13 @@ export function BoardView() {
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
onConcurrencyChange={setMaxConcurrency} onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning} isAutoModeRunning={autoMode.isRunning}
onStartAutoMode={() => autoMode.start()} onAutoModeToggle={(enabled) => {
onStopAutoMode={() => autoMode.stop()} if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)} onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{ addFeatureShortcut={{
key: shortcuts.addFeature, key: shortcuts.addFeature,
@@ -454,10 +698,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree); setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true); setShowCreateBranchDialog(true);
}} }}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks} runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({ features={hookFeatures.map((f) => ({
id: f.id, id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName, branchName: f.branchName,
}))} }))}
/> />
@@ -512,7 +756,7 @@ export function BoardView() {
onStartNextFeatures={handleStartNextFeatures} onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)} onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount} suggestionsCount={suggestionsCount}
onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/> />
</div> </div>
@@ -552,6 +796,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests} defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch} defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
@@ -564,6 +809,7 @@ export function BoardView() {
onUpdate={handleUpdateFeature} onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
@@ -580,14 +826,14 @@ export function BoardView() {
onNumberKeyPress={handleOutputModalNumberKeyPress} onNumberKeyPress={handleOutputModalNumberKeyPress}
/> />
{/* Delete All Verified Dialog */} {/* Archive All Verified Dialog */}
<DeleteAllVerifiedDialog <ArchiveAllVerifiedDialog
open={showDeleteAllVerifiedDialog} open={showArchiveAllVerifiedDialog}
onOpenChange={setShowDeleteAllVerifiedDialog} onOpenChange={setShowArchiveAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length} verifiedCount={getColumnFeatures("verified").length}
onConfirm={async () => { onConfirm={async () => {
await handleDeleteAllVerified(); await handleArchiveAllVerified();
setShowDeleteAllVerifiedDialog(false); setShowArchiveAllVerifiedDialog(false);
}} }}
/> />
@@ -657,19 +903,13 @@ export function BoardView() {
projectPath={currentProject.path} projectPath={currentProject.path}
worktree={selectedWorktreeForAction} worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => { 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) => { hookFeatures.forEach((feature) => {
const matchesByPath = // Match by branch name since worktreePath is no longer stored
feature.worktreePath && if (feature.branchName === deletedWorktree.branch) {
pathsEqual(feature.worktreePath, deletedWorktree.path); // Reset the feature's branch assignment
const matchesByBranch =
feature.branchName === deletedWorktree.branch;
if (matchesByPath || matchesByBranch) {
// Reset the feature's worktree assignment
persistFeatureUpdate(feature.id, { persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined, 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 { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button"; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider"; 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"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
interface BoardHeaderProps { interface BoardHeaderProps {
@@ -11,8 +13,7 @@ interface BoardHeaderProps {
maxConcurrency: number; maxConcurrency: number;
onConcurrencyChange: (value: number) => void; onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean; isAutoModeRunning: boolean;
onStartAutoMode: () => void; onAutoModeToggle: (enabled: boolean) => void;
onStopAutoMode: () => void;
onAddFeature: () => void; onAddFeature: () => void;
addFeatureShortcut: KeyboardShortcut; addFeatureShortcut: KeyboardShortcut;
isMounted: boolean; isMounted: boolean;
@@ -23,8 +24,7 @@ export function BoardHeader({
maxConcurrency, maxConcurrency,
onConcurrencyChange, onConcurrencyChange,
isAutoModeRunning, isAutoModeRunning,
onStartAutoMode, onAutoModeToggle,
onStopAutoMode,
onAddFeature, onAddFeature,
addFeatureShortcut, addFeatureShortcut,
isMounted, isMounted,
@@ -63,29 +63,20 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && ( {isMounted && (
<> <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
{isAutoModeRunning ? ( <Label
<Button htmlFor="auto-mode-toggle"
variant="destructive" className="text-sm font-medium cursor-pointer"
size="sm" >
onClick={onStopAutoMode} Auto Mode
data-testid="stop-auto-mode" </Label>
> <Switch
<StopCircle className="w-4 h-4 mr-2" /> id="auto-mode-toggle"
Stop Auto Mode checked={isAutoModeRunning}
</Button> onCheckedChange={onAutoModeToggle}
) : ( data-testid="auto-mode-toggle"
<Button />
variant="secondary" </div>
size="sm"
onClick={onStartAutoMode}
data-testid="start-auto-mode"
>
<Play className="w-4 h-4 mr-2" />
Auto Mode
</Button>
)}
</>
)} )}
<HotkeyButton <HotkeyButton

View File

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

View File

@@ -43,6 +43,7 @@ import {
ProfileQuickSelect, ProfileQuickSelect,
TestingTabContent, TestingTabContent,
PrioritySelector, PrioritySelector,
BranchSelector,
} from "../shared"; } from "../shared";
import { import {
DropdownMenu, DropdownMenu,
@@ -63,13 +64,14 @@ interface AddFeatureDialogProps {
skipTests: boolean; skipTests: boolean;
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
branchName: string; branchName: string; // Can be empty string to use current branch
priority: number; priority: number;
}) => void; }) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
defaultSkipTests: boolean; defaultSkipTests: boolean;
defaultBranch?: string; defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
@@ -83,10 +85,12 @@ export function AddFeatureDialog({
branchSuggestions, branchSuggestions,
defaultSkipTests, defaultSkipTests,
defaultBranch = "main", defaultBranch = "main",
currentBranch,
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
aiProfiles, aiProfiles,
}: AddFeatureDialogProps) { }: AddFeatureDialogProps) {
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({ const [newFeature, setNewFeature] = useState({
category: "", category: "",
description: "", description: "",
@@ -96,7 +100,7 @@ export function AddFeatureDialog({
skipTests: false, skipTests: false,
model: "opus" as AgentModel, model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel, thinkingLevel: "none" as ThinkingLevel,
branchName: "main", branchName: "",
priority: 2 as number, // Default to medium priority priority: 2 as number, // Default to medium priority
}); });
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -117,8 +121,9 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({ setNewFeature((prev) => ({
...prev, ...prev,
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
branchName: defaultBranch, branchName: defaultBranch || "",
})); }));
setUseCurrentBranch(true);
} }
}, [open, defaultSkipTests, defaultBranch]); }, [open, defaultSkipTests, defaultBranch]);
@@ -128,12 +133,24 @@ export function AddFeatureDialog({
return; 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 category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model; const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel) const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel ? newFeature.thinkingLevel
: "none"; : "none";
// Use current branch if toggle is on (empty string = use current), otherwise use selected branch
// Important: Don't save the actual current branch name - empty string means "use current"
const finalBranchName = useCurrentBranch
? ""
: newFeature.branchName || "";
onAdd({ onAdd({
category, category,
description: newFeature.description, description: newFeature.description,
@@ -143,7 +160,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests, skipTests: newFeature.skipTests,
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
branchName: newFeature.branchName, branchName: finalBranchName,
priority: newFeature.priority, priority: newFeature.priority,
}); });
@@ -158,8 +175,9 @@ export function AddFeatureDialog({
model: "opus", model: "opus",
priority: 2, priority: 2,
thinkingLevel: "none", thinkingLevel: "none",
branchName: defaultBranch, branchName: "",
}); });
setUseCurrentBranch(true);
setNewFeaturePreviewMap(new Map()); setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false); setShowAdvancedOptions(false);
setDescriptionError(false); setDescriptionError(false);
@@ -359,22 +377,17 @@ export function AddFeatureDialog({
/> />
</div> </div>
{useWorktrees && ( {useWorktrees && (
<div className="space-y-2"> <BranchSelector
<Label htmlFor="branch">Target Branch</Label> useCurrentBranch={useCurrentBranch}
<BranchAutocomplete onUseCurrentBranchChange={setUseCurrentBranch}
value={newFeature.branchName} branchName={newFeature.branchName}
onChange={(value) => onBranchNameChange={(value) =>
setNewFeature({ ...newFeature, branchName: value }) setNewFeature({ ...newFeature, branchName: value })
} }
branches={branchSuggestions} branchSuggestions={branchSuggestions}
placeholder="Select or create branch..." currentBranch={currentBranch}
data-testid="feature-branch-input" testIdPrefix="feature"
/> />
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if
needed.
</p>
</div>
)} )}
{/* Priority Selector */} {/* Priority Selector */}
@@ -477,6 +490,11 @@ export function AddFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }} hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open} hotkeyActive={open}
data-testid="confirm-add-feature" data-testid="confirm-add-feature"
disabled={
useWorktrees &&
!useCurrentBranch &&
!newFeature.branchName.trim()
}
> >
Add Feature Add Feature
</HotkeyButton> </HotkeyButton>

View File

@@ -0,0 +1,55 @@
"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

@@ -53,14 +53,18 @@ export function CreatePRDialog({
// Reset state when dialog opens or worktree changes // Reset state when dialog opens or worktree changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
// Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback) // Reset form fields
// These are set by the API response and should persist until dialog closes
setTitle(""); setTitle("");
setBody(""); setBody("");
setCommitMessage(""); setCommitMessage("");
setBaseBranch("main"); setBaseBranch("main");
setIsDraft(false); setIsDraft(false);
setError(null); 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);
} else { } else {
// Reset everything when dialog closes // Reset everything when dialog closes
setTitle(""); setTitle("");
@@ -105,7 +109,8 @@ export function CreatePRDialog({
onClick: () => window.open(result.result!.prUrl!, "_blank"), 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 { } else {
// Branch was pushed successfully // Branch was pushed successfully
const prError = result.result.prError; const prError = result.result.prError;
@@ -182,6 +187,9 @@ export function CreatePRDialog({
}; };
const handleClose = () => { const handleClose = () => {
// Call onCreated() to refresh worktrees when dialog closes
// This ensures the worktree list is updated after any operation
onCreated();
onOpenChange(false); onOpenChange(false);
// Reset state after dialog closes // Reset state after dialog closes
setTimeout(() => { setTimeout(() => {
@@ -228,13 +236,18 @@ export function CreatePRDialog({
Your PR is ready for review Your PR is ready for review
</p> </p>
</div> </div>
<Button <div className="flex gap-2 justify-center">
onClick={() => window.open(prUrl, "_blank")} <Button
className="gap-2" onClick={() => window.open(prUrl, "_blank")}
> className="gap-2"
<ExternalLink className="w-4 h-4" /> >
View Pull Request <ExternalLink className="w-4 h-4" />
</Button> View Pull Request
</Button>
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</div>
</div> </div>
) : shouldShowBrowserFallback ? ( ) : shouldShowBrowserFallback ? (
<div className="py-6 text-center space-y-4"> <div className="py-6 text-center space-y-4">

View File

@@ -44,6 +44,7 @@ import {
ProfileQuickSelect, ProfileQuickSelect,
TestingTabContent, TestingTabContent,
PrioritySelector, PrioritySelector,
BranchSelector,
} from "../shared"; } from "../shared";
import { import {
DropdownMenu, DropdownMenu,
@@ -66,12 +67,13 @@ interface EditFeatureDialogProps {
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[]; imagePaths: DescriptionImagePath[];
branchName: string; branchName: string; // Can be empty string to use current branch
priority: number; priority: number;
} }
) => void; ) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
currentBranch?: string;
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
@@ -84,12 +86,17 @@ export function EditFeatureDialog({
onUpdate, onUpdate,
categorySuggestions, categorySuggestions,
branchSuggestions, branchSuggestions,
currentBranch,
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
aiProfiles, aiProfiles,
allFeatures, allFeatures,
}: EditFeatureDialogProps) { }: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature); 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] = const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map()); useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -107,12 +114,27 @@ export function EditFeatureDialog({
if (!feature) { if (!feature) {
setEditFeaturePreviewMap(new Map()); setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false); setShowEditAdvancedOptions(false);
} else {
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
} }
}, [feature]); }, [feature]);
const handleUpdate = () => { const handleUpdate = () => {
if (!editingFeature) return; 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 selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking( const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel selectedModel
@@ -120,6 +142,12 @@ export function EditFeatureDialog({
? editingFeature.thinkingLevel ?? "none" ? editingFeature.thinkingLevel ?? "none"
: "none"; : "none";
// Use current branch if toggle is on (empty string = use current), otherwise use selected branch
// Important: Don't save the actual current branch name - empty string means "use current"
const finalBranchName = useCurrentBranch
? ""
: editingFeature.branchName || "";
const updates = { const updates = {
category: editingFeature.category, category: editingFeature.category,
description: editingFeature.description, description: editingFeature.description,
@@ -128,7 +156,7 @@ export function EditFeatureDialog({
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [], imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main", branchName: finalBranchName,
priority: editingFeature.priority ?? 2, priority: editingFeature.priority ?? 2,
}; };
@@ -339,33 +367,21 @@ export function EditFeatureDialog({
/> />
</div> </div>
{useWorktrees && ( {useWorktrees && (
<div className="space-y-2"> <BranchSelector
<Label htmlFor="edit-branch">Target Branch</Label> useCurrentBranch={useCurrentBranch}
<BranchAutocomplete onUseCurrentBranchChange={setUseCurrentBranch}
value={editingFeature.branchName ?? "main"} branchName={editingFeature.branchName ?? ""}
onChange={(value) => onBranchNameChange={(value) =>
setEditingFeature({ setEditingFeature({
...editingFeature, ...editingFeature,
branchName: value, branchName: value,
}) })
} }
branches={branchSuggestions} branchSuggestions={branchSuggestions}
placeholder="Select or create branch..." currentBranch={currentBranch}
data-testid="edit-feature-branch" disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== "backlog"} testIdPrefix="edit-feature"
/> />
{editingFeature.status !== "backlog" && (
<p className="text-xs text-muted-foreground">
Branch cannot be changed after work has started.
</p>
)}
{editingFeature.status === "backlog" && (
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created
if needed.
</p>
)}
</div>
)} )}
{/* Priority Selector */} {/* Priority Selector */}
@@ -486,6 +502,12 @@ export function EditFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }} hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature} hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature" data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}
> >
Save Changes Save Changes
</HotkeyButton> </HotkeyButton>

View File

@@ -1,7 +1,7 @@
export { AddFeatureDialog } from "./add-feature-dialog"; export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal"; export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-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 { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog"; export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";

View File

@@ -76,67 +76,13 @@ export function useBoardActions({
moveFeature, moveFeature,
useWorktrees, useWorktrees,
enableDependencyBlocking, enableDependencyBlocking,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore(); } = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
/** // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
* Get or create the worktree path for a feature based on its branchName. // at execution time based on feature.branchName
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[BoardActions] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error(
"[BoardActions] Failed to create worktree:",
result.error
);
toast.error("Failed to create worktree", {
description:
result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[BoardActions] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleAddFeature = useCallback( const handleAddFeature = useCallback(
async (featureData: { async (featureData: {
@@ -151,34 +97,26 @@ export function useBoardActions({
branchName: string; branchName: string;
priority: number; priority: number;
}) => { }) => {
let worktreePath: string | undefined; // Simplified: Only store branchName, no worktree creation on add
// Worktrees are created at execution time (when feature starts)
// If worktrees are enabled and a non-main branch is selected, create the worktree // Empty string means user chose "use current branch" - don't save a branch name
if (useWorktrees && featureData.branchName) { const finalBranchName =
const branchName = featureData.branchName; featureData.branchName === ""
if (branchName !== "main" && branchName !== "master") { ? undefined
// Create a temporary feature-like object for getOrCreateWorktreeForFeature : featureData.branchName || undefined;
const tempFeature = { branchName } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
const newFeatureData = { const newFeatureData = {
...featureData, ...featureData,
status: "backlog" as const, status: "backlog" as const,
worktreePath, branchName: finalBranchName,
// No worktreePath - derived at runtime from branchName
}; };
const createdFeature = addFeature(newFeatureData); const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it // Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature); await persistFeatureCreate(createdFeature);
saveCategory(featureData.category); saveCategory(featureData.category);
}, },
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] [addFeature, persistFeatureCreate, saveCategory]
); );
const handleUpdateFeature = useCallback( const handleUpdateFeature = useCallback(
@@ -196,44 +134,13 @@ export function useBoardActions({
priority: number; priority: number;
} }
) => { ) => {
// Get the current feature to check if branch is changing const finalBranchName =
const currentFeature = features.find((f) => f.id === featureId); updates.branchName === "" ? undefined : updates.branchName || undefined;
const currentBranch = currentFeature?.branchName || "main";
const newBranch = updates.branchName || "main";
const branchIsChanging = currentBranch !== newBranch;
let worktreePath: string | undefined; const finalUpdates = {
let shouldClearWorktreePath = false; ...updates,
branchName: finalBranchName,
// If worktrees are enabled and branch is changing to a non-main branch, create worktree };
if (useWorktrees && branchIsChanging) {
if (newBranch === "main" || newBranch === "master") {
// Changing to main - clear the worktreePath
shouldClearWorktreePath = true;
} else {
// Changing to a feature branch - create worktree if needed
const tempFeature = { branchName: newBranch } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
// Build final updates with worktreePath if it was changed
let finalUpdates: typeof updates & { worktreePath?: string };
if (branchIsChanging && useWorktrees) {
if (shouldClearWorktreePath) {
// Use null to clear the value in persistence (cast to work around type system)
finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
} else {
finalUpdates = { ...updates, worktreePath };
}
} else {
finalUpdates = updates;
}
updateFeature(featureId, finalUpdates); updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates); persistFeatureUpdate(featureId, finalUpdates);
@@ -242,7 +149,7 @@ export function useBoardActions({
} }
setEditingFeature(null); setEditingFeature(null);
}, },
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(
@@ -307,21 +214,18 @@ export function useBoardActions({
return; return;
} }
// Use the feature's assigned worktreePath (set when moving to in_progress) // Server derives workDir from feature.branchName at execution time
// This ensures work happens in the correct worktree based on the feature's branchName
const featureWorktreePath = feature.worktreePath;
const result = await api.autoMode.runFeature( const result = await api.autoMode.runFeature(
currentProject.path, currentProject.path,
feature.id, feature.id,
useWorktrees, useWorktrees
featureWorktreePath || undefined // No worktreePath - server derives from feature.branchName
); );
if (result.success) { if (result.success) {
console.log( console.log(
"[Board] Feature run started successfully in worktree:", "[Board] Feature run started successfully, branch:",
featureWorktreePath || "main" feature.branchName || "default"
); );
} else { } else {
console.error("[Board] Failed to run feature:", result.error); console.error("[Board] Failed to run feature:", result.error);
@@ -350,10 +254,12 @@ export function useBoardActions({
if (enableDependencyBlocking) { if (enableDependencyBlocking) {
const blockingDeps = getBlockingDependencies(feature, features); const blockingDeps = getBlockingDependencies(feature, features);
if (blockingDeps.length > 0) { if (blockingDeps.length > 0) {
const depDescriptions = blockingDeps.map(depId => { const depDescriptions = blockingDeps
const dep = features.find(f => f.id === depId); .map((depId) => {
return dep ? truncateDescription(dep.description, 40) : depId; const dep = features.find((f) => f.id === depId);
}).join(", "); return dep ? truncateDescription(dep.description, 40) : depId;
})
.join(", ");
toast.warning("Starting feature with incomplete dependencies", { toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`, description: `This feature depends on: ${depDescriptions}`,
@@ -372,7 +278,14 @@ export function useBoardActions({
await handleRunFeature(feature); await handleRunFeature(feature);
return true; return true;
}, },
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature] [
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
]
); );
const handleVerifyFeature = useCallback( const handleVerifyFeature = useCallback(
@@ -489,7 +402,6 @@ export function useBoardActions({
const featureId = followUpFeature.id; const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description; const featureDescription = followUpFeature.description;
const prompt = followUpPrompt;
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) { if (!api?.autoMode?.followUpFeature) {
@@ -521,15 +433,14 @@ export function useBoardActions({
}); });
const imagePaths = followUpImagePaths.map((img) => img.path); const imagePaths = followUpImagePaths.map((img) => img.path);
// Use the feature's worktreePath to ensure work happens in the correct branch // Server derives workDir from feature.branchName at execution time
const featureWorktreePath = followUpFeature.worktreePath;
api.autoMode api.autoMode
.followUpFeature( .followUpFeature(
currentProject.path, currentProject.path,
followUpFeature.id, followUpFeature.id,
followUpPrompt, followUpPrompt,
imagePaths, imagePaths
featureWorktreePath // No worktreePath - server derives from feature.branchName
) )
.catch((error) => { .catch((error) => {
console.error("[Board] Error sending follow-up:", error); console.error("[Board] Error sending follow-up:", error);
@@ -569,11 +480,11 @@ export function useBoardActions({
return; 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( const result = await api.autoMode.commitFeature(
currentProject.path, currentProject.path,
feature.id, feature.id
feature.worktreePath // No worktreePath - server derives from feature.branchName
); );
if (result.success) { if (result.success) {
@@ -758,23 +669,25 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => { const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch // Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list // This ensures "G" only starts features from the filtered list
const primaryBranch = projectPath
? getPrimaryWorktreeBranch(projectPath)
: null;
const backlogFeatures = features.filter((f) => { const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false; if (f.status !== "backlog") return false;
// Determine the feature's branch (default to "main" if not set) // Determine the feature's branch (default to primary branch if not set)
const featureBranch = f.branchName || "main"; const featureBranch = f.branchName || primaryBranch || "main";
// If no worktree is selected (currentWorktreeBranch is null or main-like), // If no worktree is selected (currentWorktreeBranch is null or matches primary),
// show features with no branch or "main"/"master" branch // show features with no branch or primary branch
if ( if (
!currentWorktreeBranch || !currentWorktreeBranch ||
currentWorktreeBranch === "main" || (projectPath &&
currentWorktreeBranch === "master" isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) { ) {
return ( return (
!f.branchName || !f.branchName ||
featureBranch === "main" || (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
featureBranch === "master"
); );
} }
@@ -794,57 +707,65 @@ export function useBoardActions({
} }
if (backlogFeatures.length === 0) { if (backlogFeatures.length === 0) {
const isOnPrimaryBranch =
!currentWorktreeBranch ||
(projectPath &&
isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", { toast.info("Backlog empty", {
description: description: !isOnPrimaryBranch
currentWorktreeBranch && ? `No features in backlog for branch "${currentWorktreeBranch}".`
currentWorktreeBranch !== "main" && : "No features in backlog to start.",
currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
}); });
return; return;
} }
// Sort by priority (lower number = higher priority, priority 1 is highest) // Sort by priority (lower number = higher priority, priority 1 is highest)
// This matches the auto mode service behavior for consistency // Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort( const sortedBacklog = [...backlogFeatures].sort((a, b) => {
(a, b) => (a.priority || 999) - (b.priority || 999) 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) // Start only one feature per keypress (user must press again for next)
const featuresToStart = sortedBacklog.slice(0, 1); // Simplified: No worktree creation on client - server derives workDir from feature.branchName
await handleStartImplementation(featureToStart);
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,
});
}
}, [ }, [
features, features,
runningAutoTasks, runningAutoTasks,
handleStartImplementation, handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
currentWorktreeBranch, currentWorktreeBranch,
useWorktrees, projectPath,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
]); ]);
const handleDeleteAllVerified = useCallback(async () => { const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified"); const verifiedFeatures = features.filter((f) => f.status === "verified");
for (const feature of verifiedFeatures) { for (const feature of verifiedFeatures) {
@@ -853,22 +774,29 @@ export function useBoardActions({
try { try {
await autoMode.stopFeature(feature.id); await autoMode.stopFeature(feature.id);
} catch (error) { } catch (error) {
console.error("[Board] Error stopping feature before delete:", error); console.error(
"[Board] Error stopping feature before archive:",
error
);
} }
} }
removeFeature(feature.id); // Archive the feature by setting status to completed
persistFeatureDelete(feature.id); const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
} }
toast.success("All verified features deleted", { toast.success("All verified features archived", {
description: `Deleted ${verifiedFeatures.length} feature(s).`, description: `Archived ${verifiedFeatures.length} feature(s).`,
}); });
}, [ }, [
features, features,
runningAutoTasks, runningAutoTasks,
autoMode, autoMode,
removeFeature, updateFeature,
persistFeatureDelete, persistFeatureUpdate,
]); ]);
return { return {
@@ -890,6 +818,6 @@ export function useBoardActions({
handleOutputModalNumberKeyPress, handleOutputModalNumberKeyPress,
handleForceStopFeature, handleForceStopFeature,
handleStartNextFeatures, handleStartNextFeatures,
handleDeleteAllVerified, handleArchiveAllVerified,
}; };
} }

View File

@@ -1,7 +1,6 @@
import { useMemo, useCallback } from "react"; import { useMemo, useCallback } from "react";
import { Feature } from "@/store/app-store"; import { Feature, useAppStore } from "@/store/app-store";
import { resolveDependencies } from "@/lib/dependency-resolver"; import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
import { pathsEqual } from "@/lib/utils";
type ColumnId = Feature["status"]; type ColumnId = Feature["status"];
@@ -56,26 +55,24 @@ export function useBoardColumnFeatures({
// If feature has a running agent, always show it in "in_progress" // If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id); const isRunning = runningAutoTasks.includes(f.id);
// Check if feature matches the current worktree // Check if feature matches the current worktree by branchName
// Match by worktreePath if set, OR by branchName if set // Features without branchName are considered unassigned (show only on primary worktree)
// Features with neither are considered unassigned (show on ALL worktrees) const featureBranch = f.branchName;
const featureBranch = f.branchName || "main";
const hasWorktreeAssigned = f.worktreePath || f.branchName;
let matchesWorktree: boolean; let matchesWorktree: boolean;
if (!hasWorktreeAssigned) { if (!featureBranch) {
// No worktree or branch assigned - show on ALL worktrees (unassigned) // No branch assigned - show only on primary worktree
matchesWorktree = true; const isViewingPrimary = currentWorktreePath === null;
} else if (f.worktreePath) { matchesWorktree = isViewingPrimary;
// Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
} else if (effectiveBranch === null) { } else if (effectiveBranch === null) {
// We're viewing main but branch hasn't been initialized yet // We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet). // (worktrees disabled or haven't loaded yet).
// Show features assigned to main/master branch since we're on the main worktree. // Show features assigned to primary worktree's branch.
matchesWorktree = featureBranch === "main" || featureBranch === "master"; matchesWorktree = projectPath
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
: false;
} else { } else {
// Has branchName but no worktreePath - match by branch name // Match by branch name
matchesWorktree = featureBranch === effectiveBranch; matchesWorktree = featureBranch === effectiveBranch;
} }
@@ -111,7 +108,29 @@ export function useBoardColumnFeatures({
// Within the same dependency level, features are sorted by priority // Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) { if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog); 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; return map;

View File

@@ -4,7 +4,6 @@ import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { toast } from "sonner"; import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants"; import { COLUMNS, ColumnId } from "../constants";
import { getElectronAPI } from "@/lib/electron";
interface UseBoardDragDropProps { interface UseBoardDragDropProps {
features: Feature[]; features: Feature[];
@@ -29,62 +28,10 @@ export function useBoardDragDrop({
onWorktreeCreated, onWorktreeCreated,
}: UseBoardDragDropProps) { }: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature, useWorktrees } = useAppStore(); const { moveFeature } = useAppStore();
/** // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
* Get or create the worktree path for a feature based on its branchName. // at execution time based on feature.branchName
* - If branchName is "main" or empty, returns the project path
* - Otherwise, creates a worktree for that branch if needed
*/
const getOrCreateWorktreeForFeature = useCallback(
async (feature: Feature): Promise<string | null> => {
if (!projectPath) return null;
const branchName = feature.branchName || "main";
// If targeting main branch, use the project path directly
if (branchName === "main" || branchName === "master") {
return projectPath;
}
// For other branches, create a worktree if it doesn't exist
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
console.error("[DragDrop] Worktree API not available");
return projectPath;
}
// Try to create the worktree (will return existing if already exists)
const result = await api.worktree.create(projectPath, branchName);
if (result.success && result.worktree) {
console.log(
`[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
);
if (result.worktree.isNew) {
toast.success(`Worktree created for branch "${branchName}"`, {
description: "A new worktree was created for this feature.",
});
}
return result.worktree.path;
} else {
console.error("[DragDrop] Failed to create worktree:", result.error);
toast.error("Failed to create worktree", {
description: result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
} catch (error) {
console.error("[DragDrop] Error creating worktree:", error);
toast.error("Error creating worktree", {
description: error instanceof Error ? error.message : "Unknown error",
});
return projectPath; // Fall back to project path
}
},
[projectPath]
);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
@@ -118,17 +65,13 @@ export function useBoardDragDrop({
// - Backlog items can always be dragged // - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag) // - 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) // - 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 // - in_progress items can be dragged (but not if they're currently running)
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running) // - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
if ( if (draggedFeature.status === "in_progress") {
draggedFeature.status !== "backlog" && // Only allow dragging in_progress if it's not currently running
draggedFeature.status !== "waiting_approval" && if (isRunningTask) {
draggedFeature.status !== "verified"
) {
// Only allow dragging in_progress if it's a skipTests feature and not currently running
if (!draggedFeature.skipTests || isRunningTask) {
console.log( console.log(
"[Board] Cannot drag feature - TDD feature or currently running" "[Board] Cannot drag feature - currently running"
); );
return; return;
} }
@@ -154,23 +97,13 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return; if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios // Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") { if (draggedFeature.status === "backlog") {
// From backlog // From backlog
if (targetStatus === "in_progress") { if (targetStatus === "in_progress") {
// Only create worktrees if the feature is enabled
let worktreePath: string | null = null;
if (useWorktrees) {
// Get or create worktree based on the feature's assigned branch
worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
if (worktreePath) {
await persistFeatureUpdate(featureId, { worktreePath });
}
// Refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
}
// Use helper function to handle concurrency check and start implementation // Use helper function to handle concurrency check and start implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path // Server will derive workDir from feature.branchName
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined }); await handleStartImplementation(draggedFeature);
} else { } else {
moveFeature(featureId, targetStatus); moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus }); persistFeatureUpdate(featureId, { status: targetStatus });
@@ -195,11 +128,10 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog // Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
// Clear justFinishedAt timestamp and worktreePath when moving back to backlog // Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, { persistFeatureUpdate(featureId, {
status: "backlog", status: "backlog",
justFinishedAt: undefined, justFinishedAt: undefined,
worktreePath: undefined,
}); });
toast.info("Feature moved to backlog", { toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -208,13 +140,23 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`, )}${draggedFeature.description.length > 50 ? "..." : ""}`,
}); });
} }
} else if (draggedFeature.skipTests) { } else if (draggedFeature.status === "in_progress") {
// skipTests feature being moved between in_progress and verified // Handle in_progress features being moved
if ( 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" && targetStatus === "verified" &&
draggedFeature.status === "in_progress" draggedFeature.skipTests
) { ) {
// Manual verify via drag // Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified"); moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" }); persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", { toast.success("Feature verified", {
@@ -223,7 +165,10 @@ export function useBoardDragDrop({
50 50
)}${draggedFeature.description.length > 50 ? "..." : ""}`, )}${draggedFeature.description.length > 50 ? "..." : ""}`,
}); });
} else if ( }
} else if (draggedFeature.skipTests) {
// skipTests feature being moved between verified and waiting_approval
if (
targetStatus === "waiting_approval" && targetStatus === "waiting_approval" &&
draggedFeature.status === "verified" draggedFeature.status === "verified"
) { ) {
@@ -237,10 +182,9 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`, )}${draggedFeature.description.length > 50 ? "..." : ""}`,
}); });
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog // Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
// Clear worktreePath when moving back to backlog persistFeatureUpdate(featureId, { status: "backlog" });
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
toast.info("Feature moved to backlog", { toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
0, 0,
@@ -263,8 +207,7 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") { } else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog // Allow moving verified cards back to backlog
moveFeature(featureId, "backlog"); moveFeature(featureId, "backlog");
// Clear worktreePath when moving back to backlog persistFeatureUpdate(featureId, { status: "backlog" });
persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
toast.info("Feature moved to backlog", { toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice( description: `Moved to Backlog: ${draggedFeature.description.slice(
0, 0,
@@ -280,9 +223,6 @@ export function useBoardDragDrop({
moveFeature, moveFeature,
persistFeatureUpdate, persistFeatureUpdate,
handleStartImplementation, 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 { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useAutoMode } from "@/hooks/use-auto-mode";
interface UseBoardEffectsProps { interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null; currentProject: { path: string; id: string } | null;
@@ -28,8 +27,6 @@ export function useBoardEffects({
isLoading, isLoading,
setFeaturesWithContext, setFeaturesWithContext,
}: UseBoardEffectsProps) { }: UseBoardEffectsProps) {
const autoMode = useAutoMode();
// Make current project available globally for modal // Make current project available globally for modal
useEffect(() => { useEffect(() => {
if (currentProject) { if (currentProject) {
@@ -101,8 +98,7 @@ export function useBoardEffects({
const status = await api.autoMode.status(currentProject.path); const status = await api.autoMode.status(currentProject.path);
if (status.success) { if (status.success) {
const projectId = currentProject.id; const projectId = currentProject.id;
const { clearRunningTasks, addRunningTask, setAutoModeRunning } = const { clearRunningTasks, addRunningTask } = useAppStore.getState();
useAppStore.getState();
if (status.runningFeatures) { if (status.runningFeatures) {
console.log( console.log(
@@ -116,14 +112,6 @@ export function useBoardEffects({
addRunningTask(projectId, featureId); addRunningTask(projectId, featureId);
}); });
} }
const isAutoModeRunning =
status.autoLoopRunning ?? status.isRunning ?? false;
console.log(
"[Board] Syncing auto mode running state:",
isAutoModeRunning
);
setAutoModeRunning(projectId, isAutoModeRunning);
} }
} catch (error) { } catch (error) {
console.error("[Board] Failed to sync running tasks:", 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components"; import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store"; 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 { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { COLUMNS, ColumnId } from "./constants"; import { COLUMNS, ColumnId } from "./constants";
@@ -53,7 +53,7 @@ interface KanbanBoardProps {
onStartNextFeatures: () => void; onStartNextFeatures: () => void;
onShowSuggestions: () => void; onShowSuggestions: () => void;
suggestionsCount: number; suggestionsCount: number;
onDeleteAllVerified: () => void; onArchiveAllVerified: () => void;
} }
export function KanbanBoard({ export function KanbanBoard({
@@ -83,7 +83,7 @@ export function KanbanBoard({
onStartNextFeatures, onStartNextFeatures,
onShowSuggestions, onShowSuggestions,
suggestionsCount, suggestionsCount,
onDeleteAllVerified, onArchiveAllVerified,
}: KanbanBoardProps) { }: KanbanBoardProps) {
return ( return (
<div <div
@@ -115,12 +115,12 @@ export function KanbanBoard({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10" className="h-6 px-2 text-xs"
onClick={onDeleteAllVerified} onClick={onArchiveAllVerified}
data-testid="delete-all-verified-button" data-testid="archive-all-verified-button"
> >
<Trash2 className="w-3 h-3 mr-1" /> <Archive className="w-3 h-3 mr-1" />
Delete All Archive All
</Button> </Button>
) : column.id === "backlog" ? ( ) : column.id === "backlog" ? (
<div className="flex items-center gap-1"> <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";
import { cn } from "@/lib/utils";
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 htmlFor={`${testIdPrefix}-selector`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value) => onUseCurrentBranchChange(value === "current")}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
>
<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,3 +4,4 @@ export * from "./thinking-level-selector";
export * from "./profile-quick-select"; export * from "./profile-quick-select";
export * from "./testing-tab-content"; export * from "./testing-tab-content";
export * from "./priority-selector"; export * from "./priority-selector";
export * from "./branch-selector";

View File

@@ -170,7 +170,8 @@ export function WorktreeActionsDropdown({
Commit Changes Commit Changes
</DropdownMenuItem> </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"> <DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" /> <GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request Create Pull Request

View File

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

View File

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

View File

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

View File

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

View File

@@ -213,15 +213,25 @@ export function ContextView() {
await api.writeFile(filePath, newFileContent); await api.writeFile(filePath, newFileContent);
} }
// Close dialog and reset state immediately after successful file write
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
setNewFileName(""); setNewFileName("");
setNewFileType("text"); setNewFileType("text");
setUploadedImageData(null); setUploadedImageData(null);
setNewFileContent(""); setNewFileContent("");
setIsDropHovering(false); setIsDropHovering(false);
// Load files after dialog is closed
await loadContextFiles(); await loadContextFiles();
} catch (error) { } catch (error) {
console.error("Failed to add file:", error); console.error("Failed to add file:", error);
// Still close the dialog even if loadContextFiles fails
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
} }
}; };

View File

@@ -13,7 +13,6 @@ export function useAutoMode() {
setAutoModeRunning, setAutoModeRunning,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
clearRunningTasks,
currentProject, currentProject,
addAutoModeActivity, addAutoModeActivity,
maxConcurrency, maxConcurrency,
@@ -24,7 +23,6 @@ export function useAutoMode() {
setAutoModeRunning: state.setAutoModeRunning, setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask, addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask, removeRunningTask: state.removeRunningTask,
clearRunningTasks: state.clearRunningTasks,
currentProject: state.currentProject, currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity, addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency, maxConcurrency: state.maxConcurrency,
@@ -119,33 +117,6 @@ export function useAutoMode() {
} }
break; 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": case "auto_mode_error":
console.error("[AutoMode Error]", event.error); console.error("[AutoMode Error]", event.error);
if (event.featureId && event.error) { if (event.featureId && event.error) {
@@ -218,128 +189,35 @@ export function useAutoMode() {
projectId, projectId,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
clearRunningTasks,
setAutoModeRunning, setAutoModeRunning,
addAutoModeActivity, addAutoModeActivity,
getProjectIdFromPath, getProjectIdFromPath,
]); ]);
// Restore auto mode for all projects that were running when app was closed // Start auto mode - UI only, feature pickup is handled in board-view.tsx
// This runs once on mount to restart auto loops for persisted running states const start = useCallback(() => {
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 () => {
if (!currentProject) { if (!currentProject) {
console.error("No project selected"); console.error("No project selected");
return; return;
} }
try { setAutoModeRunning(currentProject.id, true);
const api = getElectronAPI(); console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
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;
}
}, [currentProject, setAutoModeRunning, maxConcurrency]); }, [currentProject, setAutoModeRunning, maxConcurrency]);
// Stop auto mode - only turns off the toggle, running tasks continue // Stop auto mode - UI only, running tasks continue until natural completion
const stop = useCallback(async () => { const stop = useCallback(() => {
if (!currentProject) { if (!currentProject) {
console.error("No project selected"); console.error("No project selected");
return; return;
} }
try { setAutoModeRunning(currentProject.id, false);
const api = getElectronAPI(); // NOTE: We intentionally do NOT clear running tasks here.
if (!api?.autoMode) { // Stopping auto mode only turns off the toggle to prevent new features
throw new Error("Auto mode API not available"); // 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");
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;
}
}, [currentProject, setAutoModeRunning]); }, [currentProject, setAutoModeRunning]);
// Stop a specific feature // Stop a specific feature

View File

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

View File

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

View File

@@ -296,9 +296,8 @@ export interface Feature {
error?: string; // Error message if the agent errored during processing error?: string; // Error message if the agent errored during processing
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
dependencies?: string[]; // Array of feature IDs this feature depends on 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 // Branch info - worktree path is derived at runtime from branchName
worktreePath?: string; // Path to the worktree directory branchName?: string; // Name of the feature branch (undefined = use current worktree)
branchName?: string; // Name of the feature branch
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
} }
@@ -404,7 +403,10 @@ export interface AppState {
// User-managed Worktrees (per-project) // User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name } // 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< worktreesByProject: Record<
string, string,
Array<{ Array<{
@@ -588,7 +590,11 @@ export interface AppActions {
// Worktree Settings actions // Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void; setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void; setCurrentWorktree: (
projectPath: string,
worktreePath: string | null,
branch: string
) => void;
setWorktrees: ( setWorktrees: (
projectPath: string, projectPath: string,
worktrees: Array<{ worktrees: Array<{
@@ -599,7 +605,9 @@ export interface AppActions {
changedFilesCount?: number; changedFilesCount?: number;
}> }>
) => void; ) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null; getCurrentWorktree: (
projectPath: string
) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{ getWorktrees: (projectPath: string) => Array<{
path: string; path: string;
branch: string; branch: string;
@@ -607,6 +615,8 @@ export interface AppActions {
hasChanges?: boolean; hasChanges?: boolean;
changedFilesCount?: number; changedFilesCount?: number;
}>; }>;
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions // Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void; setShowProfilesOnly: (enabled: boolean) => void;
@@ -1347,7 +1357,8 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions // Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), setEnableDependencyBlocking: (enabled) =>
set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions // Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1380,6 +1391,18 @@ export const useAppStore = create<AppState & AppActions>()(
return get().worktreesByProject[projectPath] ?? []; 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 // Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }), setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2237,7 +2260,8 @@ export const useAppStore = create<AppState & AppActions>()(
// Settings // Settings
apiKeys: state.apiKeys, apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency, 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, defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking, enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees, useWorktrees: state.useWorktrees,

View File

@@ -198,30 +198,6 @@ export type AutoModeEvent =
projectId?: string; projectId?: string;
projectPath?: 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"; type: "auto_mode_phase";
featureId: string; featureId: string;
@@ -310,20 +286,6 @@ export interface SpecRegenerationAPI {
} }
export interface AutoModeAPI { 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<{ stopFeature: (featureId: string) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
@@ -331,7 +293,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{ status: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
autoLoopRunning?: boolean;
isRunning?: boolean; isRunning?: boolean;
currentFeatureId?: string | null; currentFeatureId?: string | null;
runningFeatures?: string[]; runningFeatures?: string[];
@@ -343,8 +304,7 @@ export interface AutoModeAPI {
runFeature: ( runFeature: (
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees?: boolean, useWorktrees?: boolean
worktreePath?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
passes?: boolean; passes?: boolean;
@@ -390,7 +350,7 @@ export interface AutoModeAPI {
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[], imagePaths?: string[],
worktreePath?: string useWorktrees?: boolean
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
passes?: boolean; passes?: boolean;
@@ -632,6 +592,10 @@ export interface WorktreeAPI {
hasChanges?: boolean; hasChanges?: boolean;
changedFilesCount?: number; changedFilesCount?: number;
}>; }>;
removedWorktrees?: Array<{
path: string;
branch: string;
}>;
error?: string; error?: string;
}>; }>;

View File

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

View File

@@ -137,8 +137,17 @@ export async function fillAddFeatureDialog(
// Fill branch if provided (it's a combobox autocomplete) // Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) { if (options?.branch) {
const branchButton = page.locator('[data-testid="feature-branch-input"]'); // First, select "Other branch" radio option if not already selected
await branchButton.click(); 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 // Wait for the popover to open
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Type in the command input // Type in the command input

View File

@@ -741,14 +741,15 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
await waitForBoardView(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"; const branchName = "feature/test-branch";
await apiCreateWorktree(page, testRepo.path, branchName);
// Click add feature button // Click add feature button
await clickAddFeature(page); await clickAddFeature(page);
// Fill in the feature details // Fill in the feature details with a branch name
await fillAddFeatureDialog(page, "Test feature for worktree", { await fillAddFeatureDialog(page, "Test feature for worktree", {
branch: branchName, branch: branchName,
category: "Testing", category: "Testing",
@@ -773,9 +774,12 @@ test.describe("Worktree Integration Tests", () => {
expect(featureData.description).toBe("Test feature for worktree"); expect(featureData.description).toBe("Test feature for worktree");
expect(featureData.branchName).toBe(branchName); expect(featureData.branchName).toBe(branchName);
expect(featureData.status).toBe("backlog"); 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, page,
}) => { }) => {
await setupProjectWithPath(page, testRepo.path); await setupProjectWithPath(page, testRepo.path);
@@ -783,12 +787,13 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
await waitForBoardView(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 branchName = "feature/auto-create-worktree";
const expectedWorktreePath = getWorktreePath(testRepo.path, branchName);
// Verify worktree does NOT exist before we create the feature // Verify branch does NOT exist before we create the feature
expect(fs.existsSync(expectedWorktreePath)).toBe(false); const branchesBefore = await listBranches(testRepo.path);
expect(branchesBefore).not.toContain(branchName);
// Click add feature button // Click add feature button
await clickAddFeature(page); await clickAddFeature(page);
@@ -802,17 +807,14 @@ test.describe("Worktree Integration Tests", () => {
// Confirm // Confirm
await confirmAddFeature(page); await confirmAddFeature(page);
// Wait for the worktree to be created // Wait for feature to be saved
await page.waitForTimeout(2000); await page.waitForTimeout(1000);
// Verify worktree was automatically created when feature was added // Verify branch was NOT created when adding feature (created at execution time)
expect(fs.existsSync(expectedWorktreePath)).toBe(true); const branchesAfter = await listBranches(testRepo.path);
expect(branchesAfter).not.toContain(branchName);
// Verify the branch was created // Verify feature was created with correct branch name stored
const branches = await listBranches(testRepo.path);
expect(branches).toContain(branchName);
// Verify feature was created with correct branch
const featuresDir = path.join(testRepo.path, ".automaker", "features"); const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir); const featureDirs = fs.readdirSync(featuresDir);
expect(featureDirs.length).toBeGreaterThan(0); expect(featureDirs.length).toBeGreaterThan(0);
@@ -829,8 +831,15 @@ test.describe("Worktree Integration Tests", () => {
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
// Verify branch name is stored
expect(featureData.branchName).toBe(branchName); 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 ({ 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 featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); 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.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 // Delete the worktree via UI
// Open the worktree actions menu // Open the worktree actions menu
@@ -911,10 +923,11 @@ test.describe("Worktree Integration Tests", () => {
// Verify worktree is deleted // Verify worktree is deleted
expect(fs.existsSync(worktreePath)).toBe(false); 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")); featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBeNull(); expect(featureData.branchName).toBeNull();
expect(featureData.worktreePath).toBeNull(); expect(featureData.worktreePath).toBeUndefined();
// Verify the feature appears in the backlog when main is selected // Verify the feature appears in the backlog when main is selected
const mainButton = page.getByRole("button", { name: "main" }).first(); const mainButton = page.getByRole("button", { name: "main" }).first();
@@ -940,8 +953,9 @@ test.describe("Worktree Integration Tests", () => {
await otherWorktreeButton.click(); await otherWorktreeButton.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Unassigned features should still be visible in the backlog // Unassigned features should NOT be visible on non-primary worktrees
await expect(featureText).toBeVisible({ timeout: 5000 }); // 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 }) => { test("should filter features by selected worktree", async ({ page }) => {
@@ -1062,9 +1076,11 @@ test.describe("Worktree Integration Tests", () => {
// Open add feature dialog // Open add feature dialog
await clickAddFeature(page); await clickAddFeature(page);
// Verify the branch input button shows the selected worktree's branch // Verify the branch selector shows the selected worktree's branch
const branchButton = page.locator('[data-testid="feature-branch-input"]'); // When a worktree is selected, "Use current selected branch" should be selected
await expect(branchButton).toContainText(branchName, { timeout: 5000 }); // 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 // Close dialog
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");

View File

@@ -6,8 +6,6 @@
import { Router } from "express"; import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js"; 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 { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js"; import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js"; import { createRunFeatureHandler } from "./routes/run-feature.js";
@@ -21,8 +19,6 @@ import { createCommitFeatureHandler } from "./routes/commit-feature.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router { export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router(); const router = Router();
router.post("/start", createStartHandler(autoModeService));
router.post("/stop", createStopHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService)); router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService)); router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService)); router.post("/run-feature", createRunFeatureHandler(autoModeService));

View File

@@ -12,13 +12,14 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as { const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
projectPath: string; req.body as {
featureId: string; projectPath: string;
prompt: string; featureId: string;
imagePaths?: string[]; prompt: string;
worktreePath?: string; imagePaths?: string[];
}; useWorktrees?: boolean;
};
if (!projectPath || !featureId || !prompt) { if (!projectPath || !featureId || !prompt) {
res.status(400).json({ res.status(400).json({
@@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return; 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 autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath) .followUpFeature(
projectPath,
featureId,
prompt,
imagePaths,
useWorktrees ?? true
)
.catch((error) => { .catch((error) => {
logger.error( logger.error(
`[AutoMode] Follow up feature ${featureId} error:`, `[AutoMode] Follow up feature ${featureId} error:`,
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 }); res.json({ success: true });

View File

@@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: "projectPath and featureId are required",
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
@@ -34,7 +32,8 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
.resumeFeature(projectPath, featureId, useWorktrees ?? false) .resumeFeature(projectPath, featureId, useWorktrees ?? false)
.catch((error) => { .catch((error) => {
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error); logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
}); })
.finally(() => {});
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {

View File

@@ -12,30 +12,30 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) { export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, featureId, useWorktrees, worktreePath } = req.body as { const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string; projectPath: string;
featureId: string; featureId: string;
useWorktrees?: boolean; useWorktrees?: boolean;
worktreePath?: string;
}; };
if (!projectPath || !featureId) { if (!projectPath || !featureId) {
res res.status(400).json({
.status(400) success: false,
.json({ error: "projectPath and featureId are required",
success: false, });
error: "projectPath and featureId are required",
});
return; return;
} }
// Start execution in background // Start execution in background
// If worktreePath is provided, use it directly; otherwise let the service decide // executeFeature derives workDir from feature.branchName
// Default to false - worktrees should only be used when explicitly enabled
autoModeService autoModeService
.executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath) .executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => { .catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, 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 }); 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, success: true,
runningAgents, runningAgents,
totalCount: runningAgents.length, totalCount: runningAgents.length,
autoLoopRunning: status.autoLoopRunning,
}); });
} catch (error) { } catch (error) {
logError(error, "Get running agents failed"); logError(error, "Get running agents failed");

View File

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

View File

@@ -20,14 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js"; import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, 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 type { Feature } from "./feature-loader.js";
import { import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js";
getFeatureDir,
getFeaturesDir,
getAutomakerDir,
getWorktreesDir,
} from "../lib/automaker-paths.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -41,196 +35,43 @@ interface RunningFeature {
startTime: number; startTime: number;
} }
interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
}
export class AutoModeService { export class AutoModeService {
private events: EventEmitter; private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>(); private runningFeatures = new Map<string, RunningFeature>();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
constructor(events: EventEmitter) { constructor(events: EventEmitter) {
this.events = events; this.events = events;
} }
/**
* Start the auto mode loop - continuously picks and executes pending features
*/
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
if (this.autoLoopRunning) {
throw new Error("Auto mode is already running");
}
this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController();
this.config = {
maxConcurrency,
useWorktrees: true,
projectPath,
};
this.emitAutoModeEvent("auto_mode_started", {
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
projectPath,
});
// Run the loop in the background
this.runAutoLoop().catch((error) => {
console.error("[AutoMode] Loop error:", error);
this.emitAutoModeEvent("auto_mode_error", {
error: error.message,
});
});
}
private async runAutoLoop(): Promise<void> {
while (
this.autoLoopRunning &&
this.autoLoopAbortController &&
!this.autoLoopAbortController.signal.aborted
) {
try {
// Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
await this.sleep(5000);
continue;
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(
this.config!.projectPath
);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_idle", {
message: "No pending features - auto mode idle",
projectPath: this.config!.projectPath,
});
await this.sleep(10000);
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id)
);
if (nextFeature) {
// Start feature execution in background
this.executeFeature(
this.config!.projectPath,
nextFeature.id,
this.config!.useWorktrees,
true
).catch((error) => {
console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error);
});
}
await this.sleep(2000);
} catch (error) {
console.error("[AutoMode] Loop iteration error:", error);
await this.sleep(5000);
}
}
this.autoLoopRunning = false;
}
/**
* Stop the auto mode loop
*/
async stopAutoLoop(): Promise<number> {
const wasRunning = this.autoLoopRunning;
this.autoLoopRunning = false;
if (this.autoLoopAbortController) {
this.autoLoopAbortController.abort();
this.autoLoopAbortController = null;
}
// Emit stop event immediately when user explicitly stops
if (wasRunning) {
this.emitAutoModeEvent("auto_mode_stopped", {
message: "Auto mode stopped",
projectPath: this.config?.projectPath,
});
}
return this.runningFeatures.size;
}
/** /**
* Execute a single feature * Execute a single feature
* @param projectPath - The main project path * @param projectPath - The main project path
* @param featureId - The feature ID to execute * @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation * @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode * @param isAutoMode - Whether this is running in auto mode
* @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/ */
async executeFeature( async executeFeature(
projectPath: string, projectPath: string,
featureId: string, featureId: string,
useWorktrees = false, useWorktrees = false,
isAutoMode = false, isAutoMode = false
providedWorktreePath?: string
): Promise<void> { ): Promise<void> {
if (this.runningFeatures.has(featureId)) { if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`); throw new Error(`Feature ${featureId} is already running`);
} }
const abortController = new AbortController(); // Check if feature has existing context - if so, resume instead of starting fresh
const branchName = `feature/${featureId}`; const hasExistingContext = await this.contextExists(projectPath, featureId);
let worktreePath: string | null = null; if (hasExistingContext) {
console.log(
// Use provided worktree path if given, otherwise setup new worktree if enabled `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
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
); );
return this.resumeFeature(projectPath, featureId, useWorktrees);
} }
// Ensure workDir is always an absolute path for cross-platform compatibility const abortController = new AbortController();
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
this.runningFeatures.set(featureId, { // Emit feature start event early
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Emit feature start event
this.emitAutoModeEvent("auto_mode_feature_start", { this.emitAutoModeEvent("auto_mode_feature_start", {
featureId, featureId,
projectPath, projectPath,
@@ -242,12 +83,53 @@ export class AutoModeService {
}); });
try { try {
// Load feature details // Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId); const feature = await this.loadFeature(projectPath, featureId);
if (!feature) { if (!feature) {
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
// Derive workDir from feature.branchName
// If no branchName, use the project path directly
let worktreePath: string | null = null;
const branchName = feature.branchName || null;
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);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode,
startTime: Date.now(),
});
// Update feature status to in_progress // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress"); await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -262,7 +144,7 @@ export class AutoModeService {
// Get model from feature // Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log( 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 // Run the agent with the feature's model and images
@@ -271,6 +153,7 @@ export class AutoModeService {
featureId, featureId,
prompt, prompt,
abortController, abortController,
projectPath,
imagePaths, imagePaths,
model model
); );
@@ -371,7 +254,7 @@ export class AutoModeService {
featureId: string, featureId: string,
prompt: string, prompt: string,
imagePaths?: string[], imagePaths?: string[],
providedWorktreePath?: string useWorktrees = true
): Promise<void> { ): Promise<void> {
if (this.runningFeatures.has(featureId)) { if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`); throw new Error(`Feature ${featureId} is already running`);
@@ -379,32 +262,29 @@ export class AutoModeService {
const abortController = new AbortController(); const abortController = new AbortController();
// Use the provided worktreePath (from the feature's assigned branch) // Load feature info for context FIRST to get branchName
// Fall back to project path if not provided const feature = await this.loadFeature(projectPath, featureId);
// Derive workDir from feature.branchName
let workDir = path.resolve(projectPath); let workDir = path.resolve(projectPath);
let worktreePath: string | null = null; let worktreePath: string | null = null;
const branchName = feature?.branchName || null;
if (providedWorktreePath) { if (useWorktrees && branchName) {
try { // Try to find existing worktree for this branch
// Resolve to absolute path - critical for cross-platform compatibility worktreePath = await this.findExistingWorktreeForBranch(
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd projectPath,
// On all platforms, absolute paths ensure commands execute in the correct directory branchName
const resolvedPath = path.isAbsolute(providedWorktreePath) );
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath); if (worktreePath) {
workDir = worktreePath;
await fs.access(resolvedPath); console.log(
workDir = resolvedPath; `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`
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`);
} }
} }
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists // Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId); const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md"); const contextPath = path.join(featureDir, "agent-output.md");
@@ -441,7 +321,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId, featureId,
projectPath, projectPath,
worktreePath, worktreePath,
branchName: worktreePath ? path.basename(worktreePath) : null, branchName,
abortController, abortController,
isAutoMode: false, isAutoMode: false,
startTime: Date.now(), startTime: Date.now(),
@@ -537,6 +417,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId, featureId,
fullPrompt, fullPrompt,
abortController, abortController,
projectPath,
allImagePaths.length > 0 ? allImagePaths : imagePaths, allImagePaths.length > 0 ? allImagePaths : imagePaths,
model, model,
previousContext || undefined previousContext || undefined
@@ -653,17 +534,25 @@ Address the follow-up instructions above. Review the previous work and make the
workDir = providedWorktreePath; workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`); console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch { } 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 { } else {
// Fallback: try to find worktree at legacy location // Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId); const legacyWorktreePath = path.join(
projectPath,
".worktrees",
featureId
);
try { try {
await fs.access(legacyWorktreePath); await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath; workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch { } catch {
console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); console.log(
`[AutoMode] No worktree found, committing in project path: ${workDir}`
);
} }
} }
@@ -816,13 +705,11 @@ Format your response as a structured markdown document.`;
*/ */
getStatus(): { getStatus(): {
isRunning: boolean; isRunning: boolean;
autoLoopRunning: boolean;
runningFeatures: string[]; runningFeatures: string[];
runningCount: number; runningCount: number;
} { } {
return { return {
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0, isRunning: this.runningFeatures.size > 0,
autoLoopRunning: this.autoLoopRunning,
runningFeatures: Array.from(this.runningFeatures.keys()), runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size, runningCount: this.runningFeatures.size,
}; };
@@ -905,10 +792,15 @@ Format your response as a structured markdown document.`;
branchName: string branchName: string
): Promise<string> { ): Promise<string> {
// First, check if git already has a worktree for this branch (anywhere) // 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) { if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch // 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; return existingWorktree;
} }
@@ -992,56 +884,6 @@ Format your response as a structured markdown document.`;
} }
} }
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking)
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(
featuresDir,
entry.name,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === "pending" ||
feature.status === "ready" ||
feature.status === "backlog"
) {
pendingFeatures.push(feature);
}
} catch {
// Skip invalid features
}
}
}
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
} catch {
return [];
}
}
/** /**
* Extract a title from feature description (first line or truncated) * Extract a title from feature description (first line or truncated)
*/ */
@@ -1060,31 +902,6 @@ Format your response as a structured markdown document.`;
return firstLine.substring(0, 57) + "..."; return firstLine.substring(0, 57) + "...";
} }
/**
* Extract image paths from feature's imagePaths array
* Handles both string paths and objects with path property
*/
private extractImagePaths(
imagePaths:
| Array<string | { path: string; [key: string]: unknown }>
| undefined,
projectPath: string
): string[] {
if (!imagePaths || imagePaths.length === 0) {
return [];
}
return imagePaths
.map((imgPath) => {
const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path;
// Resolve relative paths to absolute paths
return path.isAbsolute(pathStr)
? pathStr
: path.join(projectPath, pathStr);
})
.filter((p) => p); // Filter out any empty paths
}
private buildFeaturePrompt(feature: Feature): string { private buildFeaturePrompt(feature: Feature): string {
const title = this.extractTitleFromDescription(feature.description); const title = this.extractTitleFromDescription(feature.description);
@@ -1164,6 +981,7 @@ This helps parse your summary correctly in the output logs.`;
featureId: string, featureId: string,
prompt: string, prompt: string,
abortController: AbortController, abortController: AbortController,
projectPath: string,
imagePaths?: string[], imagePaths?: string[],
model?: string, model?: string,
previousContent?: string previousContent?: string
@@ -1171,7 +989,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 // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing // This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") { 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 // Simulate some work being done
await this.sleep(500); await this.sleep(500);
@@ -1203,8 +1023,7 @@ This helps parse your summary correctly in the output logs.`;
await this.sleep(200); await this.sleep(200);
// Save mock agent output // Save mock agent output
const configProjectPath = this.config?.projectPath || workDir; const featureDirForOutput = getFeatureDir(projectPath, featureId);
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md"); const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output const mockOutput = `# Mock Agent Output
@@ -1222,7 +1041,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput); 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; return;
} }
@@ -1273,10 +1094,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: ""; : "";
// Agent output goes to .automaker directory // Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir // Note: We use projectPath here, not workDir, because workDir might be a worktree path
// because workDir might be a worktree path const featureDirForOutput = getFeatureDir(projectPath, featureId);
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md"); const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state // Incremental file writing state
@@ -1290,7 +1109,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.writeFile(outputPath, responseText); await fs.writeFile(outputPath, responseText);
} catch (error) { } catch (error) {
// Log but don't crash - file write errors shouldn't stop execution // 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
);
} }
}; };
@@ -1309,11 +1131,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === "text") { if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines // 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.length > 0 && !responseText.endsWith("\n\n")) {
if (responseText.endsWith('\n')) { if (responseText.endsWith("\n")) {
responseText += '\n'; responseText += "\n";
} else { } else {
responseText += '\n\n'; responseText += "\n\n";
} }
} }
responseText += block.text || ""; responseText += block.text || "";
@@ -1347,12 +1169,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}); });
// Also add to file output for persistence // Also add to file output for persistence
if (responseText.length > 0 && !responseText.endsWith('\n')) { if (responseText.length > 0 && !responseText.endsWith("\n")) {
responseText += '\n'; responseText += "\n";
} }
responseText += `\n🔧 Tool: ${block.name}\n`; responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) { if (block.input) {
responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; responseText += `Input: ${JSON.stringify(
block.input,
null,
2
)}\n`;
} }
scheduleWrite(); scheduleWrite();
} }
@@ -1382,12 +1208,68 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
context: string, context: string,
useWorktrees: boolean useWorktrees: boolean
): Promise<void> { ): Promise<void> {
const feature = await this.loadFeature(projectPath, featureId); if (this.runningFeatures.has(featureId)) {
if (!feature) { throw new Error(`Feature ${featureId} is already running`);
throw new Error(`Feature ${featureId} not found`);
} }
const prompt = `## Continuing Feature Implementation const abortController = new AbortController();
// Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: {
id: featureId,
title: "Resuming...",
description: "Feature is resuming from previous context",
},
});
try {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName
let worktreePath: string | null = null;
const branchName = feature.branchName || null;
if (useWorktrees && branchName) {
worktreePath = await this.findExistingWorktreeForBranch(
projectPath,
branchName
);
if (!worktreePath) {
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
console.log(
`[AutoMode] Resuming in worktree for branch "${branchName}": ${worktreePath}`
);
}
const workDir = worktreePath
? path.resolve(worktreePath)
: path.resolve(projectPath);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
});
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
const prompt = `## Continuing Feature Implementation
${this.buildFeaturePrompt(feature)} ${this.buildFeaturePrompt(feature)}
@@ -1399,7 +1281,67 @@ ${context}
## Instructions ## Instructions
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
return this.executeFeature(projectPath, featureId, useWorktrees, false); // Extract image paths from feature
const imagePaths = feature.imagePaths?.map((img) =>
typeof img === "string" ? img : img.path
);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
`[AutoMode] Resuming feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with context
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
imagePaths,
model,
context // Pass previous context for proper file output
);
// Mark as waiting_approval for user review
await this.updateFeatureStatus(
projectPath,
featureId,
"waiting_approval"
);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: true,
message: `Feature resumed and completed in ${Math.round(
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
)}s`,
projectPath,
});
} catch (error) {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
message: "Feature stopped by user",
projectPath,
});
} else {
console.error(`[AutoMode] Feature ${featureId} resume failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: errorInfo.message,
errorType: errorInfo.isAuth ? "authentication" : "execution",
projectPath,
});
}
} finally {
this.runningFeatures.delete(featureId);
}
} }
/** /**
@@ -1418,7 +1360,28 @@ Review the previous work and continue the implementation. If the feature appears
}); });
} }
private sleep(ms: number): Promise<void> { private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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; spec?: string;
model?: string; model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>; 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)
[key: string]: unknown; [key: string]: unknown;
} }

629
package-lock.json generated
View File

@@ -33,8 +33,10 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
@@ -1546,10 +1548,6 @@
"version": "1.1.1", "version": "1.1.1",
"license": "MIT" "license": "MIT"
}, },
"apps/app/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"license": "MIT"
},
"apps/app/node_modules/@radix-ui/react-arrow": { "apps/app/node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"license": "MIT", "license": "MIT",
@@ -1599,72 +1597,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"license": "MIT",
"dependencies": {
"@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-slot": "1.2.3"
},
"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-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-dialog": { "apps/app/node_modules/@radix-ui/react-dialog": {
"version": "1.1.15", "version": "1.1.15",
"license": "MIT", "license": "MIT",
@@ -1715,19 +1647,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-dismissable-layer": { "apps/app/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11", "version": "1.1.11",
"license": "MIT", "license": "MIT",
@@ -1816,22 +1735,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-label": { "apps/app/node_modules/@radix-ui/react-label": {
"version": "2.1.8", "version": "2.1.8",
"license": "MIT", "license": "MIT",
@@ -2031,94 +1934,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-primitive": {
"version": "2.1.3",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"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-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"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": { "apps/app/node_modules/@radix-ui/react-slider": {
"version": "1.3.6", "version": "1.3.6",
"license": "MIT", "license": "MIT",
@@ -2242,52 +2057,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-use-escape-keydown": { "apps/app/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1", "version": "1.1.1",
"license": "MIT", "license": "MIT",
@@ -2304,32 +2073,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-use-rect": { "apps/app/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"license": "MIT", "license": "MIT",
@@ -2346,22 +2089,6 @@
} }
} }
}, },
"apps/app/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"apps/app/node_modules/@radix-ui/react-visually-hidden": { "apps/app/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3", "version": "1.2.3",
"license": "MIT", "license": "MIT",
@@ -11195,6 +10922,358 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@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-slot": "1.2.3"
},
"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-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"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",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"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-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-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"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",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",