diff --git a/apps/app/package.json b/apps/app/package.json
index ad9100db..d518c90e 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -45,8 +45,10 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
diff --git a/apps/app/src/components/ui/autocomplete.tsx b/apps/app/src/components/ui/autocomplete.tsx
index 23e094c6..7417a97e 100644
--- a/apps/app/src/components/ui/autocomplete.tsx
+++ b/apps/app/src/components/ui/autocomplete.tsx
@@ -35,6 +35,7 @@ interface AutocompleteProps {
emptyMessage?: string;
className?: string;
disabled?: boolean;
+ error?: boolean;
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
@@ -58,6 +59,7 @@ export function Autocomplete({
emptyMessage = "No results found.",
className,
disabled = false,
+ error = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
@@ -130,6 +132,7 @@ export function Autocomplete({
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
+ error && "border-destructive focus-visible:ring-destructive",
className
)}
data-testid={testId}
diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx
index 60838354..b2d76913 100644
--- a/apps/app/src/components/ui/branch-autocomplete.tsx
+++ b/apps/app/src/components/ui/branch-autocomplete.tsx
@@ -11,6 +11,7 @@ interface BranchAutocompleteProps {
placeholder?: string;
className?: string;
disabled?: boolean;
+ error?: boolean;
"data-testid"?: string;
}
@@ -21,6 +22,7 @@ export function BranchAutocomplete({
placeholder = "Select a branch...",
className,
disabled = false,
+ error = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
@@ -43,6 +45,7 @@ export function BranchAutocomplete({
emptyMessage="No branches found."
className={className}
disabled={disabled}
+ error={error}
icon={GitBranch}
allowCreate
createLabel={(v) => `Create "${v}"`}
diff --git a/apps/app/src/components/ui/radio-group.tsx b/apps/app/src/components/ui/radio-group.tsx
new file mode 100644
index 00000000..a62d67dc
--- /dev/null
+++ b/apps/app/src/components/ui/radio-group.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };
+
diff --git a/apps/app/src/components/ui/switch.tsx b/apps/app/src/components/ui/switch.tsx
new file mode 100644
index 00000000..24d00673
--- /dev/null
+++ b/apps/app/src/components/ui/switch.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
+
diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx
index 3a0fb270..a0dd35f0 100644
--- a/apps/app/src/components/views/board-view.tsx
+++ b/apps/app/src/components/views/board-view.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState, useCallback, useMemo } from "react";
+import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {
PointerSensor,
useSensor,
@@ -10,7 +10,9 @@ import {
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
+import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
+import { getBlockingDependencies } from "@/lib/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -25,7 +27,7 @@ import {
AddFeatureDialog,
AgentOutputModal,
CompletedFeaturesModal,
- DeleteAllVerifiedDialog,
+ ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
@@ -72,6 +74,10 @@ export function BoardView() {
setCurrentWorktree,
getWorktrees,
setWorktrees,
+ useWorktrees,
+ enableDependencyBlocking,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -89,7 +95,7 @@ export function BoardView() {
const [featuresWithContext, setFeaturesWithContext] = useState>(
new Set()
);
- const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
+ const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
@@ -277,6 +283,27 @@ export function BoardView() {
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
+ // Memoize the removed worktrees handler to prevent infinite loops
+ const handleRemovedWorktrees = useCallback(
+ (removedWorktrees: Array<{ path: string; branch: string }>) => {
+ // Reset features that were assigned to the removed worktrees (by branch)
+ hookFeatures.forEach((feature) => {
+ const matchesRemovedWorktree = removedWorktrees.some((removed) => {
+ // Match by branch name since worktreePath is no longer stored
+ return feature.branchName === removed.branch;
+ });
+
+ if (matchesRemovedWorktree) {
+ // Reset the feature's branch assignment
+ persistFeatureUpdate(feature.id, {
+ branchName: null as unknown as string | undefined,
+ });
+ }
+ });
+ },
+ [hookFeatures, persistFeatureUpdate]
+ );
+
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
@@ -285,13 +312,12 @@ export function BoardView() {
});
}, [hookFeatures, runningAutoTasks]);
- // Get current worktree info (path and branch) for filtering features
+ // Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
- const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
@@ -301,8 +327,25 @@ export function BoardView() {
[currentProject, worktreesByProject]
);
+ // Get the branch for the currently selected worktree
+ // Find the worktree that matches the current selection, or use main worktree
+ const selectedWorktree = useMemo(() => {
+ if (currentWorktreePath === null) {
+ // Primary worktree selected - find the main worktree
+ return worktrees.find((w) => w.isMain);
+ } else {
+ // Specific worktree selected - find it by path
+ return worktrees.find(
+ (w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
+ );
+ }
+ }, [worktrees, currentWorktreePath]);
+
+ // Get the current branch from the selected worktree (not from store which may be stale)
+ const currentWorktreeBranch = selectedWorktree?.branch ?? null;
+
// Get the branch for the currently selected worktree (for defaulting new features)
- // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
+ // Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
@@ -326,7 +369,7 @@ export function BoardView() {
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
- handleDeleteAllVerified,
+ handleArchiveAllVerified,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -354,6 +397,202 @@ export function BoardView() {
currentWorktreeBranch,
});
+ // Client-side auto mode: periodically check for backlog items and move them to in-progress
+ // Use a ref to track the latest auto mode state so async operations always check the current value
+ const autoModeRunningRef = useRef(autoMode.isRunning);
+ useEffect(() => {
+ autoModeRunningRef.current = autoMode.isRunning;
+ }, [autoMode.isRunning]);
+
+ // Track features that are pending (started but not yet confirmed running)
+ const pendingFeaturesRef = useRef>(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)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -422,8 +661,13 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
- onStartAutoMode={() => autoMode.start()}
- onStopAutoMode={() => autoMode.stop()}
+ onAutoModeToggle={(enabled) => {
+ if (enabled) {
+ autoMode.start();
+ } else {
+ autoMode.stop();
+ }
+ }}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
@@ -454,10 +698,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
+ onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({
id: f.id,
- worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
@@ -512,7 +756,7 @@ export function BoardView() {
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
- onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
+ onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
@@ -552,6 +796,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
+ currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -564,6 +809,7 @@ export function BoardView() {
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
+ currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -580,14 +826,14 @@ export function BoardView() {
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
- {/* Delete All Verified Dialog */}
- {
- await handleDeleteAllVerified();
- setShowDeleteAllVerifiedDialog(false);
+ await handleArchiveAllVerified();
+ setShowArchiveAllVerifiedDialog(false);
}}
/>
@@ -657,19 +903,13 @@ export function BoardView() {
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => {
- // Reset features that were assigned to the deleted worktree
+ // Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
- const matchesByPath =
- feature.worktreePath &&
- pathsEqual(feature.worktreePath, deletedWorktree.path);
- const matchesByBranch =
- feature.branchName === deletedWorktree.branch;
-
- if (matchesByPath || matchesByBranch) {
- // Reset the feature's worktree assignment
+ // Match by branch name since worktreePath is no longer stored
+ if (feature.branchName === deletedWorktree.branch) {
+ // Reset the feature's branch assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
- worktreePath: null as unknown as string | undefined,
});
}
});
diff --git a/apps/app/src/components/views/board-view/board-header.tsx b/apps/app/src/components/views/board-view/board-header.tsx
index 844abd8d..4340ff48 100644
--- a/apps/app/src/components/views/board-view/board-header.tsx
+++ b/apps/app/src/components/views/board-view/board-header.tsx
@@ -3,7 +3,9 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
-import { Play, StopCircle, Plus, Users } from "lucide-react";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { Plus, Users } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
interface BoardHeaderProps {
@@ -11,8 +13,7 @@ interface BoardHeaderProps {
maxConcurrency: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
- onStartAutoMode: () => void;
- onStopAutoMode: () => void;
+ onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
@@ -23,8 +24,7 @@ export function BoardHeader({
maxConcurrency,
onConcurrencyChange,
isAutoModeRunning,
- onStartAutoMode,
- onStopAutoMode,
+ onAutoModeToggle,
onAddFeature,
addFeatureShortcut,
isMounted,
@@ -63,29 +63,20 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
- <>
- {isAutoModeRunning ? (
-
- ) : (
-
- )}
- >
+
+
+
+
)}
void;
categorySuggestions: string[];
branchSuggestions: string[];
defaultSkipTests: boolean;
defaultBranch?: string;
+ currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -83,10 +85,12 @@ export function AddFeatureDialog({
branchSuggestions,
defaultSkipTests,
defaultBranch = "main",
+ currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
}: AddFeatureDialogProps) {
+ const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
category: "",
description: "",
@@ -96,7 +100,7 @@ export function AddFeatureDialog({
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
- branchName: "main",
+ branchName: "",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
@@ -117,8 +121,9 @@ export function AddFeatureDialog({
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
- branchName: defaultBranch,
+ branchName: defaultBranch || "",
}));
+ setUseCurrentBranch(true);
}
}, [open, defaultSkipTests, defaultBranch]);
@@ -128,12 +133,24 @@ export function AddFeatureDialog({
return;
}
+ // Validate branch selection when "other branch" is selected
+ if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
+ toast.error("Please select a branch name");
+ return;
+ }
+
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
+ // Use current branch if toggle is on (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({
category,
description: newFeature.description,
@@ -143,7 +160,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
- branchName: newFeature.branchName,
+ branchName: finalBranchName,
priority: newFeature.priority,
});
@@ -158,8 +175,9 @@ export function AddFeatureDialog({
model: "opus",
priority: 2,
thinkingLevel: "none",
- branchName: defaultBranch,
+ branchName: "",
});
+ setUseCurrentBranch(true);
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
@@ -359,22 +377,17 @@ export function AddFeatureDialog({
/>
{useWorktrees && (
-
-
-
- setNewFeature({ ...newFeature, branchName: value })
- }
- branches={branchSuggestions}
- placeholder="Select or create branch..."
- data-testid="feature-branch-input"
- />
-
- Work will be done in this branch. A worktree will be created if
- needed.
-
-
+
+ setNewFeature({ ...newFeature, branchName: value })
+ }
+ branchSuggestions={branchSuggestions}
+ currentBranch={currentBranch}
+ testIdPrefix="feature"
+ />
)}
{/* Priority Selector */}
@@ -477,6 +490,11 @@ export function AddFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
+ disabled={
+ useWorktrees &&
+ !useCurrentBranch &&
+ !newFeature.branchName.trim()
+ }
>
Add Feature
diff --git a/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx
new file mode 100644
index 00000000..66674648
--- /dev/null
+++ b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx
@@ -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 (
+
+ );
+}
+
diff --git a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx
index 6c6a2048..31f58103 100644
--- a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx
+++ b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx
@@ -53,14 +53,18 @@ export function CreatePRDialog({
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
- // Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
- // These are set by the API response and should persist until dialog closes
+ // Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
+ // Also reset result states when opening for a new worktree
+ // This prevents showing stale PR URLs from previous worktrees
+ setPrUrl(null);
+ setBrowserUrl(null);
+ setShowBrowserFallback(false);
} else {
// Reset everything when dialog closes
setTitle("");
@@ -105,7 +109,8 @@ export function CreatePRDialog({
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
- onCreated();
+ // Don't call onCreated() here - keep dialog open to show success message
+ // onCreated() will be called when user closes the dialog
} else {
// Branch was pushed successfully
const prError = result.result.prError;
@@ -182,6 +187,9 @@ export function CreatePRDialog({
};
const handleClose = () => {
+ // Call onCreated() to refresh worktrees when dialog closes
+ // This ensures the worktree list is updated after any operation
+ onCreated();
onOpenChange(false);
// Reset state after dialog closes
setTimeout(() => {
@@ -228,13 +236,18 @@ export function CreatePRDialog({
Your PR is ready for review
-
+
+
+
+
) : shouldShowBrowserFallback ? (
diff --git a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
index 4fed0b13..e02a499e 100644
--- a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
+++ b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
@@ -44,6 +44,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
+ BranchSelector,
} from "../shared";
import {
DropdownMenu,
@@ -66,12 +67,13 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
- branchName: string;
+ branchName: string; // Can be empty string to use current branch
priority: number;
}
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
+ currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -84,12 +86,17 @@ export function EditFeatureDialog({
onUpdate,
categorySuggestions,
branchSuggestions,
+ currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState(feature);
+ const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
+ // If feature has no branchName, default to using current branch
+ return !feature?.branchName;
+ });
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -107,12 +114,27 @@ export function EditFeatureDialog({
if (!feature) {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
+ } else {
+ // If feature has no branchName, default to using current branch
+ setUseCurrentBranch(!feature.branchName);
}
}, [feature]);
const handleUpdate = () => {
if (!editingFeature) return;
+ // Validate branch selection when "other branch" is selected and branch selector is enabled
+ const isBranchSelectorEnabled = editingFeature.status === "backlog";
+ if (
+ useWorktrees &&
+ isBranchSelectorEnabled &&
+ !useCurrentBranch &&
+ !editingFeature.branchName?.trim()
+ ) {
+ toast.error("Please select a branch name");
+ return;
+ }
+
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
@@ -120,6 +142,12 @@ export function EditFeatureDialog({
? editingFeature.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
+ ? ""
+ : editingFeature.branchName || "";
+
const updates = {
category: editingFeature.category,
description: editingFeature.description,
@@ -128,7 +156,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
- branchName: editingFeature.branchName ?? "main",
+ branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
};
@@ -339,33 +367,21 @@ export function EditFeatureDialog({
/>
{useWorktrees && (
-
-
-
- setEditingFeature({
- ...editingFeature,
- branchName: value,
- })
- }
- branches={branchSuggestions}
- placeholder="Select or create branch..."
- data-testid="edit-feature-branch"
- disabled={editingFeature.status !== "backlog"}
- />
- {editingFeature.status !== "backlog" && (
-
- Branch cannot be changed after work has started.
-
- )}
- {editingFeature.status === "backlog" && (
-
- Work will be done in this branch. A worktree will be created
- if needed.
-
- )}
-
+
+ setEditingFeature({
+ ...editingFeature,
+ branchName: value,
+ })
+ }
+ branchSuggestions={branchSuggestions}
+ currentBranch={currentBranch}
+ disabled={editingFeature.status !== "backlog"}
+ testIdPrefix="edit-feature"
+ />
)}
{/* Priority Selector */}
@@ -486,6 +502,12 @@ export function EditFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
+ disabled={
+ useWorktrees &&
+ editingFeature.status === "backlog" &&
+ !useCurrentBranch &&
+ !editingFeature.branchName?.trim()
+ }
>
Save Changes
diff --git a/apps/app/src/components/views/board-view/dialogs/index.ts b/apps/app/src/components/views/board-view/dialogs/index.ts
index 5685ddcb..92575a32 100644
--- a/apps/app/src/components/views/board-view/dialogs/index.ts
+++ b/apps/app/src/components/views/board-view/dialogs/index.ts
@@ -1,7 +1,7 @@
export { AddFeatureDialog } from "./add-feature-dialog";
export { AgentOutputModal } from "./agent-output-modal";
export { CompletedFeaturesModal } from "./completed-features-modal";
-export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog";
+export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts
index 8a5f2d04..9eb10208 100644
--- a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts
+++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts
@@ -76,67 +76,13 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
} = useAppStore();
const autoMode = useAutoMode();
- /**
- * Get or create the worktree path for a feature based on its branchName.
- * - If branchName is "main" or empty, returns the project path
- * - Otherwise, creates a worktree for that branch if needed
- */
- const getOrCreateWorktreeForFeature = useCallback(
- async (feature: Feature): Promise => {
- if (!projectPath) return null;
-
- const branchName = feature.branchName || "main";
-
- // If targeting main branch, use the project path directly
- if (branchName === "main" || branchName === "master") {
- return projectPath;
- }
-
- // For other branches, create a worktree if it doesn't exist
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.create) {
- console.error("[BoardActions] Worktree API not available");
- return projectPath;
- }
-
- // Try to create the worktree (will return existing if already exists)
- const result = await api.worktree.create(projectPath, branchName);
-
- if (result.success && result.worktree) {
- console.log(
- `[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
- );
- if (result.worktree.isNew) {
- toast.success(`Worktree created for branch "${branchName}"`, {
- description: "A new worktree was created for this feature.",
- });
- }
- return result.worktree.path;
- } else {
- console.error(
- "[BoardActions] Failed to create worktree:",
- result.error
- );
- toast.error("Failed to create worktree", {
- description:
- result.error || "Could not create worktree for this branch.",
- });
- return projectPath; // Fall back to project path
- }
- } catch (error) {
- console.error("[BoardActions] Error creating worktree:", error);
- toast.error("Error creating worktree", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- return projectPath; // Fall back to project path
- }
- },
- [projectPath]
- );
+ // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
+ // at execution time based on feature.branchName
const handleAddFeature = useCallback(
async (featureData: {
@@ -151,34 +97,26 @@ export function useBoardActions({
branchName: string;
priority: number;
}) => {
- let worktreePath: string | undefined;
-
- // If worktrees are enabled and a non-main branch is selected, create the worktree
- if (useWorktrees && featureData.branchName) {
- const branchName = featureData.branchName;
- if (branchName !== "main" && branchName !== "master") {
- // Create a temporary feature-like object for getOrCreateWorktreeForFeature
- const tempFeature = { branchName } as Feature;
- const path = await getOrCreateWorktreeForFeature(tempFeature);
- if (path && path !== projectPath) {
- worktreePath = path;
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- }
- }
+ // Simplified: Only store branchName, no worktree creation on add
+ // Worktrees are created at execution time (when feature starts)
+ // Empty string means user chose "use current branch" - don't save a branch name
+ const finalBranchName =
+ featureData.branchName === ""
+ ? undefined
+ : featureData.branchName || undefined;
const newFeatureData = {
...featureData,
status: "backlog" as const,
- worktreePath,
+ branchName: finalBranchName,
+ // No worktreePath - derived at runtime from branchName
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
- [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
+ [addFeature, persistFeatureCreate, saveCategory]
);
const handleUpdateFeature = useCallback(
@@ -196,44 +134,13 @@ export function useBoardActions({
priority: number;
}
) => {
- // Get the current feature to check if branch is changing
- const currentFeature = features.find((f) => f.id === featureId);
- const currentBranch = currentFeature?.branchName || "main";
- const newBranch = updates.branchName || "main";
- const branchIsChanging = currentBranch !== newBranch;
+ const finalBranchName =
+ updates.branchName === "" ? undefined : updates.branchName || undefined;
- let worktreePath: string | undefined;
- let shouldClearWorktreePath = false;
-
- // If worktrees are enabled and branch is changing to a non-main branch, create worktree
- if (useWorktrees && branchIsChanging) {
- if (newBranch === "main" || newBranch === "master") {
- // Changing to main - clear the worktreePath
- shouldClearWorktreePath = true;
- } else {
- // Changing to a feature branch - create worktree if needed
- const tempFeature = { branchName: newBranch } as Feature;
- const path = await getOrCreateWorktreeForFeature(tempFeature);
- if (path && path !== projectPath) {
- worktreePath = path;
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- }
- }
-
- // Build final updates with worktreePath if it was changed
- let finalUpdates: typeof updates & { worktreePath?: string };
- if (branchIsChanging && useWorktrees) {
- if (shouldClearWorktreePath) {
- // Use null to clear the value in persistence (cast to work around type system)
- finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
- } else {
- finalUpdates = { ...updates, worktreePath };
- }
- } else {
- finalUpdates = updates;
- }
+ const finalUpdates = {
+ ...updates,
+ branchName: finalBranchName,
+ };
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
@@ -242,7 +149,7 @@ export function useBoardActions({
}
setEditingFeature(null);
},
- [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
+ [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
);
const handleDeleteFeature = useCallback(
@@ -307,21 +214,18 @@ export function useBoardActions({
return;
}
- // Use the feature's assigned worktreePath (set when moving to in_progress)
- // This ensures work happens in the correct worktree based on the feature's branchName
- const featureWorktreePath = feature.worktreePath;
-
+ // Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
- useWorktrees,
- featureWorktreePath || undefined
+ useWorktrees
+ // No worktreePath - server derives from feature.branchName
);
if (result.success) {
console.log(
- "[Board] Feature run started successfully in worktree:",
- featureWorktreePath || "main"
+ "[Board] Feature run started successfully, branch:",
+ feature.branchName || "default"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
@@ -350,10 +254,12 @@ export function useBoardActions({
if (enableDependencyBlocking) {
const blockingDeps = getBlockingDependencies(feature, features);
if (blockingDeps.length > 0) {
- const depDescriptions = blockingDeps.map(depId => {
- const dep = features.find(f => f.id === depId);
- return dep ? truncateDescription(dep.description, 40) : depId;
- }).join(", ");
+ const depDescriptions = blockingDeps
+ .map((depId) => {
+ const dep = features.find((f) => f.id === depId);
+ return dep ? truncateDescription(dep.description, 40) : depId;
+ })
+ .join(", ");
toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`,
@@ -372,7 +278,14 @@ export function useBoardActions({
await handleRunFeature(feature);
return true;
},
- [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
+ [
+ autoMode,
+ enableDependencyBlocking,
+ features,
+ updateFeature,
+ persistFeatureUpdate,
+ handleRunFeature,
+ ]
);
const handleVerifyFeature = useCallback(
@@ -489,7 +402,6 @@ export function useBoardActions({
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
- const prompt = followUpPrompt;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
@@ -521,15 +433,14 @@ export function useBoardActions({
});
const imagePaths = followUpImagePaths.map((img) => img.path);
- // Use the feature's worktreePath to ensure work happens in the correct branch
- const featureWorktreePath = followUpFeature.worktreePath;
+ // Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
- imagePaths,
- featureWorktreePath
+ imagePaths
+ // No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
@@ -569,11 +480,11 @@ export function useBoardActions({
return;
}
- // Pass the feature's worktreePath to ensure commits happen in the correct worktree
+ // Server derives workDir from feature.branchName
const result = await api.autoMode.commitFeature(
currentProject.path,
- feature.id,
- feature.worktreePath
+ feature.id
+ // No worktreePath - server derives from feature.branchName
);
if (result.success) {
@@ -758,23 +669,25 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
+ const primaryBranch = projectPath
+ ? getPrimaryWorktreeBranch(projectPath)
+ : null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
- // Determine the feature's branch (default to "main" if not set)
- const featureBranch = f.branchName || "main";
+ // Determine the feature's branch (default to primary branch if not set)
+ const featureBranch = f.branchName || primaryBranch || "main";
- // If no worktree is selected (currentWorktreeBranch is null or main-like),
- // show features with no branch or "main"/"master" branch
+ // If no worktree is selected (currentWorktreeBranch is null or matches primary),
+ // show features with no branch or primary branch
if (
!currentWorktreeBranch ||
- currentWorktreeBranch === "main" ||
- currentWorktreeBranch === "master"
+ (projectPath &&
+ isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
- featureBranch === "main" ||
- featureBranch === "master"
+ (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -794,57 +707,65 @@ export function useBoardActions({
}
if (backlogFeatures.length === 0) {
+ const isOnPrimaryBranch =
+ !currentWorktreeBranch ||
+ (projectPath &&
+ isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
- description:
- currentWorktreeBranch &&
- currentWorktreeBranch !== "main" &&
- currentWorktreeBranch !== "master"
- ? `No features in backlog for branch "${currentWorktreeBranch}".`
- : "No features in backlog to start.",
+ description: !isOnPrimaryBranch
+ ? `No features in backlog for branch "${currentWorktreeBranch}".`
+ : "No features in backlog to start.",
});
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
- // This matches the auto mode service behavior for consistency
- const sortedBacklog = [...backlogFeatures].sort(
- (a, b) => (a.priority || 999) - (b.priority || 999)
- );
+ // Features with blocking dependencies are sorted to the end
+ const sortedBacklog = [...backlogFeatures].sort((a, b) => {
+ const aBlocked = enableDependencyBlocking
+ ? getBlockingDependencies(a, features).length > 0
+ : false;
+ const bBlocked = enableDependencyBlocking
+ ? getBlockingDependencies(b, features).length > 0
+ : false;
+
+ // Blocked features go to the end
+ if (aBlocked && !bBlocked) return 1;
+ if (!aBlocked && bBlocked) return -1;
+
+ // Within same blocked/unblocked group, sort by priority
+ return (a.priority || 999) - (b.priority || 999);
+ });
+
+ // Find the first feature without blocking dependencies
+ const featureToStart = sortedBacklog.find((f) => {
+ if (!enableDependencyBlocking) return true;
+ return getBlockingDependencies(f, features).length === 0;
+ });
+
+ if (!featureToStart) {
+ toast.info("No eligible features", {
+ description:
+ "All backlog features have unmet dependencies. Complete their dependencies first.",
+ });
+ return;
+ }
// Start only one feature per keypress (user must press again for next)
- const featuresToStart = sortedBacklog.slice(0, 1);
-
- for (const feature of featuresToStart) {
- // Only create worktrees if the feature is enabled
- let worktreePath: string | null = null;
- if (useWorktrees) {
- // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
- worktreePath = await getOrCreateWorktreeForFeature(feature);
- if (worktreePath) {
- await persistFeatureUpdate(feature.id, { worktreePath });
- }
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- // Start the implementation
- // Pass feature with worktreePath so handleRunFeature uses the correct path
- await handleStartImplementation({
- ...feature,
- worktreePath: worktreePath || undefined,
- });
- }
+ // Simplified: No worktree creation on client - server derives workDir from feature.branchName
+ await handleStartImplementation(featureToStart);
}, [
features,
runningAutoTasks,
handleStartImplementation,
- getOrCreateWorktreeForFeature,
- persistFeatureUpdate,
- onWorktreeCreated,
currentWorktreeBranch,
- useWorktrees,
+ projectPath,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
+ enableDependencyBlocking,
]);
- const handleDeleteAllVerified = useCallback(async () => {
+ const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
for (const feature of verifiedFeatures) {
@@ -853,22 +774,29 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
- console.error("[Board] Error stopping feature before delete:", error);
+ console.error(
+ "[Board] Error stopping feature before archive:",
+ error
+ );
}
}
- removeFeature(feature.id);
- persistFeatureDelete(feature.id);
+ // Archive the feature by setting status to completed
+ const updates = {
+ status: "completed" as const,
+ };
+ updateFeature(feature.id, updates);
+ persistFeatureUpdate(feature.id, updates);
}
- toast.success("All verified features deleted", {
- description: `Deleted ${verifiedFeatures.length} feature(s).`,
+ toast.success("All verified features archived", {
+ description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
- removeFeature,
- persistFeatureDelete,
+ updateFeature,
+ persistFeatureUpdate,
]);
return {
@@ -890,6 +818,6 @@ export function useBoardActions({
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
- handleDeleteAllVerified,
+ handleArchiveAllVerified,
};
}
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts
index f09a0135..24810f41 100644
--- a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts
+++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts
@@ -1,7 +1,6 @@
import { useMemo, useCallback } from "react";
-import { Feature } from "@/store/app-store";
-import { resolveDependencies } from "@/lib/dependency-resolver";
-import { pathsEqual } from "@/lib/utils";
+import { Feature, useAppStore } from "@/store/app-store";
+import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver";
type ColumnId = Feature["status"];
@@ -56,26 +55,24 @@ export function useBoardColumnFeatures({
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
- // Check if feature matches the current worktree
- // Match by worktreePath if set, OR by branchName if set
- // Features with neither are considered unassigned (show on ALL worktrees)
- const featureBranch = f.branchName || "main";
- const hasWorktreeAssigned = f.worktreePath || f.branchName;
+ // Check if feature matches the current worktree by branchName
+ // Features without branchName are considered unassigned (show only on primary worktree)
+ const featureBranch = f.branchName;
let matchesWorktree: boolean;
- if (!hasWorktreeAssigned) {
- // No worktree or branch assigned - show on ALL worktrees (unassigned)
- matchesWorktree = true;
- } else if (f.worktreePath) {
- // Has worktreePath - match by path (use pathsEqual for cross-platform compatibility)
- matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath);
+ if (!featureBranch) {
+ // No branch assigned - show only on primary worktree
+ const isViewingPrimary = currentWorktreePath === null;
+ matchesWorktree = isViewingPrimary;
} else if (effectiveBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// (worktrees disabled or haven't loaded yet).
- // Show features assigned to main/master branch since we're on the main worktree.
- matchesWorktree = featureBranch === "main" || featureBranch === "master";
+ // Show features assigned to primary worktree's branch.
+ matchesWorktree = projectPath
+ ? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
+ : false;
} else {
- // Has branchName but no worktreePath - match by branch name
+ // Match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
@@ -111,7 +108,29 @@ export function useBoardColumnFeatures({
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
- map.backlog = orderedFeatures;
+
+ // Get all features to check blocking dependencies against
+ const allFeatures = features;
+ const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
+
+ // Sort blocked features to the end of the backlog
+ // This keeps the dependency order within each group (unblocked/blocked)
+ if (enableDependencyBlocking) {
+ const unblocked: Feature[] = [];
+ const blocked: Feature[] = [];
+
+ for (const f of orderedFeatures) {
+ if (getBlockingDependencies(f, allFeatures).length > 0) {
+ blocked.push(f);
+ } else {
+ unblocked.push(f);
+ }
+ }
+
+ map.backlog = [...unblocked, ...blocked];
+ } else {
+ map.backlog = orderedFeatures;
+ }
}
return map;
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts
index e9016a8e..92366e5b 100644
--- a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts
+++ b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts
@@ -4,7 +4,6 @@ import { Feature } from "@/store/app-store";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { COLUMNS, ColumnId } from "../constants";
-import { getElectronAPI } from "@/lib/electron";
interface UseBoardDragDropProps {
features: Feature[];
@@ -29,62 +28,10 @@ export function useBoardDragDrop({
onWorktreeCreated,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState(null);
- const { moveFeature, useWorktrees } = useAppStore();
+ const { moveFeature } = useAppStore();
- /**
- * Get or create the worktree path for a feature based on its branchName.
- * - If branchName is "main" or empty, returns the project path
- * - Otherwise, creates a worktree for that branch if needed
- */
- const getOrCreateWorktreeForFeature = useCallback(
- async (feature: Feature): Promise => {
- if (!projectPath) return null;
-
- const branchName = feature.branchName || "main";
-
- // If targeting main branch, use the project path directly
- if (branchName === "main" || branchName === "master") {
- return projectPath;
- }
-
- // For other branches, create a worktree if it doesn't exist
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.create) {
- console.error("[DragDrop] Worktree API not available");
- return projectPath;
- }
-
- // Try to create the worktree (will return existing if already exists)
- const result = await api.worktree.create(projectPath, branchName);
-
- if (result.success && result.worktree) {
- console.log(
- `[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}`
- );
- if (result.worktree.isNew) {
- toast.success(`Worktree created for branch "${branchName}"`, {
- description: "A new worktree was created for this feature.",
- });
- }
- return result.worktree.path;
- } else {
- console.error("[DragDrop] Failed to create worktree:", result.error);
- toast.error("Failed to create worktree", {
- description: result.error || "Could not create worktree for this branch.",
- });
- return projectPath; // Fall back to project path
- }
- } catch (error) {
- console.error("[DragDrop] Error creating worktree:", error);
- toast.error("Error creating worktree", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- return projectPath; // Fall back to project path
- }
- },
- [projectPath]
- );
+ // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
+ // at execution time based on feature.branchName
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -118,17 +65,13 @@ export function useBoardDragDrop({
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
- // - skipTests (non-TDD) items can be dragged between in_progress and verified
- // - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
- if (
- draggedFeature.status !== "backlog" &&
- draggedFeature.status !== "waiting_approval" &&
- draggedFeature.status !== "verified"
- ) {
- // Only allow dragging in_progress if it's a skipTests feature and not currently running
- if (!draggedFeature.skipTests || isRunningTask) {
+ // - in_progress items can be dragged (but not if they're currently running)
+ // - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
+ if (draggedFeature.status === "in_progress") {
+ // Only allow dragging in_progress if it's not currently running
+ if (isRunningTask) {
console.log(
- "[Board] Cannot drag feature - TDD feature or currently running"
+ "[Board] Cannot drag feature - currently running"
);
return;
}
@@ -154,23 +97,13 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios
+ // Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
- // Only create worktrees if the feature is enabled
- let worktreePath: string | null = null;
- if (useWorktrees) {
- // Get or create worktree based on the feature's assigned branch
- worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
- if (worktreePath) {
- await persistFeatureUpdate(featureId, { worktreePath });
- }
- // Refresh worktree selector after moving to in_progress
- onWorktreeCreated?.();
- }
// Use helper function to handle concurrency check and start implementation
- // Pass feature with worktreePath so handleRunFeature uses the correct path
- await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
+ // Server will derive workDir from feature.branchName
+ await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -195,11 +128,10 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
- // Clear justFinishedAt timestamp and worktreePath when moving back to backlog
+ // Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
- worktreePath: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -208,13 +140,23 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
- } else if (draggedFeature.skipTests) {
- // skipTests feature being moved between in_progress and verified
- if (
+ } else if (draggedFeature.status === "in_progress") {
+ // Handle in_progress features being moved
+ if (targetStatus === "backlog") {
+ // Allow moving in_progress cards back to backlog
+ moveFeature(featureId, "backlog");
+ persistFeatureUpdate(featureId, { status: "backlog" });
+ toast.info("Feature moved to backlog", {
+ description: `Moved to Backlog: ${draggedFeature.description.slice(
+ 0,
+ 50
+ )}${draggedFeature.description.length > 50 ? "..." : ""}`,
+ });
+ } else if (
targetStatus === "verified" &&
- draggedFeature.status === "in_progress"
+ draggedFeature.skipTests
) {
- // Manual verify via drag
+ // Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
@@ -223,7 +165,10 @@ export function useBoardDragDrop({
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
- } else if (
+ }
+ } else if (draggedFeature.skipTests) {
+ // skipTests feature being moved between verified and waiting_approval
+ if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
@@ -237,10 +182,9 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
- // Allow moving skipTests cards back to backlog
+ // Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
- // Clear worktreePath when moving back to backlog
- persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
+ persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -263,8 +207,7 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
- // Clear worktreePath when moving back to backlog
- persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
+ persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -280,9 +223,6 @@ export function useBoardDragDrop({
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
- getOrCreateWorktreeForFeature,
- onWorktreeCreated,
- useWorktrees,
]
);
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-effects.ts b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts
index a2784a8d..7aa80c3a 100644
--- a/apps/app/src/components/views/board-view/hooks/use-board-effects.ts
+++ b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts
@@ -1,7 +1,6 @@
-import { useEffect, useRef } from "react";
+import { useEffect } from "react";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
-import { useAutoMode } from "@/hooks/use-auto-mode";
interface UseBoardEffectsProps {
currentProject: { path: string; id: string } | null;
@@ -28,8 +27,6 @@ export function useBoardEffects({
isLoading,
setFeaturesWithContext,
}: UseBoardEffectsProps) {
- const autoMode = useAutoMode();
-
// Make current project available globally for modal
useEffect(() => {
if (currentProject) {
@@ -101,8 +98,7 @@ export function useBoardEffects({
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
- const { clearRunningTasks, addRunningTask, setAutoModeRunning } =
- useAppStore.getState();
+ const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
console.log(
@@ -116,14 +112,6 @@ export function useBoardEffects({
addRunningTask(projectId, featureId);
});
}
-
- const isAutoModeRunning =
- status.autoLoopRunning ?? status.isRunning ?? false;
- console.log(
- "[Board] Syncing auto mode running state:",
- isAutoModeRunning
- );
- setAutoModeRunning(projectId, isAutoModeRunning);
}
} catch (error) {
console.error("[Board] Failed to sync running tasks:", error);
diff --git a/apps/app/src/components/views/board-view/kanban-board.tsx b/apps/app/src/components/views/board-view/kanban-board.tsx
index 77bd6cc1..94eb0a84 100644
--- a/apps/app/src/components/views/board-view/kanban-board.tsx
+++ b/apps/app/src/components/views/board-view/kanban-board.tsx
@@ -14,7 +14,7 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
-import { FastForward, Lightbulb, Trash2 } from "lucide-react";
+import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { COLUMNS, ColumnId } from "./constants";
@@ -53,7 +53,7 @@ interface KanbanBoardProps {
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
- onDeleteAllVerified: () => void;
+ onArchiveAllVerified: () => void;
}
export function KanbanBoard({
@@ -83,7 +83,7 @@ export function KanbanBoard({
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
- onDeleteAllVerified,
+ onArchiveAllVerified,
}: KanbanBoardProps) {
return (
-
- Delete All
+
+ Archive All
) : column.id === "backlog" ? (
diff --git a/apps/app/src/components/views/board-view/shared/branch-selector.tsx b/apps/app/src/components/views/board-view/shared/branch-selector.tsx
new file mode 100644
index 00000000..54737887
--- /dev/null
+++ b/apps/app/src/components/views/board-view/shared/branch-selector.tsx
@@ -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 (
+
+
+
onUseCurrentBranchChange(value === "current")}
+ disabled={disabled}
+ data-testid={`${testIdPrefix}-radio-group`}
+ >
+
+
+
+
+
+
+
+
+
+ {!useCurrentBranch && (
+
+
+ {hasError && (
+
+ Branch name is required when "Other branch" is selected.
+
+ )}
+
+ )}
+ {disabled ? (
+
+ Branch cannot be changed after work has started.
+
+ ) : (
+
+ {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."}
+
+ )}
+
+ );
+}
+
diff --git a/apps/app/src/components/views/board-view/shared/index.ts b/apps/app/src/components/views/board-view/shared/index.ts
index 913aa3e5..c5f9bd4f 100644
--- a/apps/app/src/components/views/board-view/shared/index.ts
+++ b/apps/app/src/components/views/board-view/shared/index.ts
@@ -4,3 +4,4 @@ export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
+export * from "./branch-selector";
diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
index b0cc7870..cf94cfe3 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
+++ b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
@@ -170,7 +170,8 @@ export function WorktreeActionsDropdown({
Commit Changes
)}
- {(worktree.branch !== "main" || worktree.hasChanges) && (
+ {/* Show PR option for non-primary worktrees, or primary worktree with changes */}
+ {(!worktree.isMain || worktree.hasChanges) && (
onCreatePR(worktree)} className="text-xs">
Create Pull Request
diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
index 46bc7af1..a0275ebc 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
+++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
@@ -1,47 +1,35 @@
"use client";
import { useCallback } from "react";
-import { pathsEqual } from "@/lib/utils";
import type { WorktreeInfo, FeatureInfo } from "../types";
interface UseRunningFeaturesOptions {
- projectPath: string;
runningFeatureIds: string[];
features: FeatureInfo[];
- getWorktreeKey: (worktree: WorktreeInfo) => string;
}
export function useRunningFeatures({
- projectPath,
runningFeatureIds,
features,
- getWorktreeKey,
}: UseRunningFeaturesOptions) {
const hasRunningFeatures = useCallback(
(worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
- const worktreeKey = getWorktreeKey(worktree);
-
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
- if (feature.worktreePath) {
- if (worktree.isMain) {
- return pathsEqual(feature.worktreePath, projectPath);
- }
- return pathsEqual(feature.worktreePath, worktreeKey);
- }
-
+ // Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
return worktree.branch === feature.branchName;
}
+ // No branch assigned - belongs to main worktree
return worktree.isMain;
});
},
- [runningFeatureIds, features, projectPath, getWorktreeKey]
+ [runningFeatureIds, features]
);
return {
diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
index 39b3ae60..aed28926 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
+++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
@@ -9,9 +9,10 @@ import type { WorktreeInfo } from "../types";
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
+ onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
}
-export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) {
+export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState([]);
@@ -34,8 +35,11 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
setWorktrees(result.worktrees);
setWorktreesInStore(projectPath, result.worktrees);
}
+ // Return removed worktrees so they can be handled by the caller
+ return result.removedWorktrees;
} catch (error) {
console.error("Failed to fetch worktrees:", error);
+ return undefined;
} finally {
setIsLoading(false);
}
@@ -47,9 +51,13 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
useEffect(() => {
if (refreshTrigger > 0) {
- fetchWorktrees();
+ fetchWorktrees().then((removedWorktrees) => {
+ if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
+ onRemovedWorktrees(removedWorktrees);
+ }
+ });
}
- }, [refreshTrigger, fetchWorktrees]);
+ }, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
useEffect(() => {
if (worktrees.length > 0) {
@@ -59,6 +67,8 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
+ // Find the primary worktree and get its branch name
+ // Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch);
diff --git a/apps/app/src/components/views/board-view/worktree-panel/types.ts b/apps/app/src/components/views/board-view/worktree-panel/types.ts
index 630aa953..e143ae73 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/types.ts
+++ b/apps/app/src/components/views/board-view/worktree-panel/types.ts
@@ -22,7 +22,6 @@ export interface DevServerInfo {
export interface FeatureInfo {
id: string;
- worktreePath?: string;
branchName?: string;
}
@@ -33,6 +32,7 @@ export interface WorktreePanelProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
+ onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
refreshTrigger?: number;
diff --git a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx
index 53470fd8..ddd27892 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx
+++ b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx
@@ -21,6 +21,7 @@ export function WorktreePanel({
onCommit,
onCreatePR,
onCreateBranch,
+ onRemovedWorktrees,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
@@ -33,7 +34,7 @@ export function WorktreePanel({
useWorktreesEnabled,
fetchWorktrees,
handleSelectWorktree,
- } = useWorktrees({ projectPath, refreshTrigger });
+ } = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
const {
isStartingDevServer,
@@ -74,10 +75,8 @@ export function WorktreePanel({
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
- projectPath,
runningFeatureIds,
features,
- getWorktreeKey,
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
@@ -163,7 +162,12 @@ export function WorktreePanel({
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
- onClick={fetchWorktrees}
+ onClick={async () => {
+ const removedWorktrees = await fetchWorktrees();
+ if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
+ onRemovedWorktrees(removedWorktrees);
+ }
+ }}
disabled={isLoading}
title="Refresh worktrees"
>
diff --git a/apps/app/src/components/views/context-view.tsx b/apps/app/src/components/views/context-view.tsx
index 126c1afe..27d78e35 100644
--- a/apps/app/src/components/views/context-view.tsx
+++ b/apps/app/src/components/views/context-view.tsx
@@ -213,15 +213,25 @@ export function ContextView() {
await api.writeFile(filePath, newFileContent);
}
+ // Close dialog and reset state immediately after successful file write
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
+
+ // Load files after dialog is closed
await loadContextFiles();
} catch (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);
}
};
diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts
index 4fac3f41..f1690a48 100644
--- a/apps/app/src/hooks/use-auto-mode.ts
+++ b/apps/app/src/hooks/use-auto-mode.ts
@@ -13,7 +13,6 @@ export function useAutoMode() {
setAutoModeRunning,
addRunningTask,
removeRunningTask,
- clearRunningTasks,
currentProject,
addAutoModeActivity,
maxConcurrency,
@@ -24,7 +23,6 @@ export function useAutoMode() {
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
- clearRunningTasks: state.clearRunningTasks,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
@@ -119,33 +117,6 @@ export function useAutoMode() {
}
break;
- case "auto_mode_stopped":
- // Auto mode was explicitly stopped (by user or error)
- setAutoModeRunning(eventProjectId, false);
- clearRunningTasks(eventProjectId);
- console.log("[AutoMode] Auto mode stopped");
- break;
-
- case "auto_mode_started":
- // Auto mode started - ensure UI reflects running state
- console.log("[AutoMode] Auto mode started:", event.message);
- break;
-
- case "auto_mode_idle":
- // Auto mode is running but has no pending features to pick up
- // This is NOT a stop - auto mode keeps running and will pick up new features
- console.log("[AutoMode] Auto mode idle - waiting for new features");
- break;
-
- case "auto_mode_complete":
- // Legacy event - only handle if it looks like a stop (for backwards compatibility)
- if (event.message === "Auto mode stopped") {
- setAutoModeRunning(eventProjectId, false);
- clearRunningTasks(eventProjectId);
- console.log("[AutoMode] Auto mode stopped (legacy event)");
- }
- break;
-
case "auto_mode_error":
console.error("[AutoMode Error]", event.error);
if (event.featureId && event.error) {
@@ -218,128 +189,35 @@ export function useAutoMode() {
projectId,
addRunningTask,
removeRunningTask,
- clearRunningTasks,
setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
]);
- // Restore auto mode for all projects that were running when app was closed
- // This runs once on mount to restart auto loops for persisted running states
- useEffect(() => {
- const api = getElectronAPI();
- if (!api?.autoMode) return;
-
- // Find all projects that have auto mode marked as running
- const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
- [];
- for (const [projectId, state] of Object.entries(autoModeByProject)) {
- if (state.isRunning) {
- // Find the project path for this project ID
- const project = projects.find((p) => p.id === projectId);
- if (project) {
- projectsToRestart.push({ projectId, projectPath: project.path });
- }
- }
- }
-
- // Restart auto mode for each project
- for (const { projectId, projectPath } of projectsToRestart) {
- console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
- api.autoMode
- .start(projectPath, maxConcurrency)
- .then((result) => {
- if (!result.success) {
- console.error(
- `[AutoMode] Failed to restore auto mode for ${projectPath}:`,
- result.error
- );
- // Mark as not running if we couldn't restart
- setAutoModeRunning(projectId, false);
- } else {
- console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
- }
- })
- .catch((error) => {
- console.error(
- `[AutoMode] Error restoring auto mode for ${projectPath}:`,
- error
- );
- setAutoModeRunning(projectId, false);
- });
- }
- // Only run once on mount - intentionally empty dependency array
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Start auto mode
- const start = useCallback(async () => {
+ // Start auto mode - UI only, feature pickup is handled in board-view.tsx
+ const start = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- throw new Error("Auto mode API not available");
- }
-
- const result = await api.autoMode.start(
- currentProject.path,
- maxConcurrency
- );
-
- if (result.success) {
- setAutoModeRunning(currentProject.id, true);
- console.log(
- `[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
- );
- } else {
- console.error("[AutoMode] Failed to start:", result.error);
- throw new Error(result.error || "Failed to start auto mode");
- }
- } catch (error) {
- console.error("[AutoMode] Error starting:", error);
- if (currentProject) {
- setAutoModeRunning(currentProject.id, false);
- }
- throw error;
- }
+ setAutoModeRunning(currentProject.id, true);
+ console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
}, [currentProject, setAutoModeRunning, maxConcurrency]);
- // Stop auto mode - only turns off the toggle, running tasks continue
- const stop = useCallback(async () => {
+ // Stop auto mode - UI only, running tasks continue until natural completion
+ const stop = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- throw new Error("Auto mode API not available");
- }
-
- const result = await api.autoMode.stop(currentProject.path);
-
- if (result.success) {
- setAutoModeRunning(currentProject.id, false);
- // NOTE: We intentionally do NOT clear running tasks here.
- // Stopping auto mode only turns off the toggle to prevent new features
- // from being picked up. Running tasks will complete naturally and be
- // removed via the auto_mode_feature_complete event.
- console.log(
- "[AutoMode] Stopped successfully - running tasks will continue"
- );
- } else {
- console.error("[AutoMode] Failed to stop:", result.error);
- throw new Error(result.error || "Failed to stop auto mode");
- }
- } catch (error) {
- console.error("[AutoMode] Error stopping:", error);
- throw error;
- }
+ setAutoModeRunning(currentProject.id, false);
+ // NOTE: We intentionally do NOT clear running tasks here.
+ // Stopping auto mode only turns off the toggle to prevent new features
+ // from being picked up. Running tasks will complete naturally and be
+ // removed via the auto_mode_feature_complete event.
+ console.log("[AutoMode] Stopped - running tasks will continue");
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature
diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts
index 5fff0d55..a73574a3 100644
--- a/apps/app/src/lib/electron.ts
+++ b/apps/app/src/lib/electron.ts
@@ -80,7 +80,6 @@ export interface RunningAgentsResult {
success: boolean;
runningAgents?: RunningAgent[];
totalCount?: number;
- autoLoopRunning?: boolean;
error?: string;
}
@@ -217,7 +216,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
isRunning?: boolean;
- autoLoopRunning?: boolean; // Backend uses this name instead of isRunning
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
@@ -1442,7 +1440,6 @@ function createMockAutoModeAPI(): AutoModeAPI {
return {
success: true,
isRunning: mockAutoModeRunning,
- autoLoopRunning: mockAutoModeRunning,
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
runningFeatures: Array.from(mockRunningFeatures),
runningCount: mockRunningFeatures.size,
@@ -2593,7 +2590,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
success: true,
runningAgents,
totalCount: runningAgents.length,
- autoLoopRunning: mockAutoModeRunning,
};
},
};
diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts
index d007b806..3fe6fdd2 100644
--- a/apps/app/src/lib/http-api-client.ts
+++ b/apps/app/src/lib/http-api-client.ts
@@ -738,7 +738,6 @@ export class HttpApiClient implements ElectronAPI {
isAutoMode: boolean;
}>;
totalCount?: number;
- autoLoopRunning?: boolean;
error?: string;
}> => this.get("/api/running-agents"),
};
diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts
index 41033199..2c7d2b5d 100644
--- a/apps/app/src/store/app-store.ts
+++ b/apps/app/src/store/app-store.ts
@@ -296,9 +296,8 @@ export interface Feature {
error?: string; // Error message if the agent errored during processing
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
dependencies?: string[]; // Array of feature IDs this feature depends on
- // Worktree info - set when a feature is being worked on in an isolated git worktree
- worktreePath?: string; // Path to the worktree directory
- branchName?: string; // Name of the feature branch
+ // Branch info - worktree path is derived at runtime from branchName
+ branchName?: string; // Name of the feature branch (undefined = use current worktree)
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
}
@@ -404,7 +403,10 @@ export interface AppState {
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
- currentWorktreeByProject: Record;
+ currentWorktreeByProject: Record<
+ string,
+ { path: string | null; branch: string }
+ >;
worktreesByProject: Record<
string,
Array<{
@@ -588,7 +590,11 @@ export interface AppActions {
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
- setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
+ setCurrentWorktree: (
+ projectPath: string,
+ worktreePath: string | null,
+ branch: string
+ ) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
@@ -599,7 +605,9 @@ export interface AppActions {
changedFilesCount?: number;
}>
) => void;
- getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
+ getCurrentWorktree: (
+ projectPath: string
+ ) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
@@ -607,6 +615,8 @@ export interface AppActions {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
+ isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
+ getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
@@ -1347,7 +1357,8 @@ export const useAppStore = create()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
- setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
+ setEnableDependencyBlocking: (enabled) =>
+ set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1380,6 +1391,18 @@ export const useAppStore = create()(
return get().worktreesByProject[projectPath] ?? [];
},
+ isPrimaryWorktreeBranch: (projectPath, branchName) => {
+ const worktrees = get().worktreesByProject[projectPath] ?? [];
+ const primary = worktrees.find((w) => w.isMain);
+ return primary?.branch === branchName;
+ },
+
+ getPrimaryWorktreeBranch: (projectPath) => {
+ const worktrees = get().worktreesByProject[projectPath] ?? [];
+ const primary = worktrees.find((w) => w.isMain);
+ return primary?.branch ?? null;
+ },
+
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2237,7 +2260,8 @@ export const useAppStore = create()(
// Settings
apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency,
- autoModeByProject: state.autoModeByProject,
+ // Note: autoModeByProject is intentionally NOT persisted
+ // Auto-mode should always default to OFF on app refresh
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts
index 4a0f973b..aa938ac6 100644
--- a/apps/app/src/types/electron.d.ts
+++ b/apps/app/src/types/electron.d.ts
@@ -198,30 +198,6 @@ export type AutoModeEvent =
projectId?: string;
projectPath?: string;
}
- | {
- type: "auto_mode_complete";
- message: string;
- projectId?: string;
- projectPath?: string;
- }
- | {
- type: "auto_mode_stopped";
- message: string;
- projectId?: string;
- projectPath?: string;
- }
- | {
- type: "auto_mode_started";
- message: string;
- projectId?: string;
- projectPath?: string;
- }
- | {
- type: "auto_mode_idle";
- message: string;
- projectId?: string;
- projectPath?: string;
- }
| {
type: "auto_mode_phase";
featureId: string;
@@ -310,20 +286,6 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
- start: (
- projectPath: string,
- maxConcurrency?: number
- ) => Promise<{
- success: boolean;
- error?: string;
- }>;
-
- stop: (projectPath: string) => Promise<{
- success: boolean;
- error?: string;
- runningFeatures?: number;
- }>;
-
stopFeature: (featureId: string) => Promise<{
success: boolean;
error?: string;
@@ -331,7 +293,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
- autoLoopRunning?: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
@@ -343,8 +304,7 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
- useWorktrees?: boolean,
- worktreePath?: string
+ useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -390,7 +350,7 @@ export interface AutoModeAPI {
featureId: string,
prompt: string,
imagePaths?: string[],
- worktreePath?: string
+ useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -632,6 +592,10 @@ export interface WorktreeAPI {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
+ removedWorktrees?: Array<{
+ path: string;
+ branch: string;
+ }>;
error?: string;
}>;
diff --git a/apps/app/tests/utils/core/constants.ts b/apps/app/tests/utils/core/constants.ts
index 935436c0..922e4bc6 100644
--- a/apps/app/tests/utils/core/constants.ts
+++ b/apps/app/tests/utils/core/constants.ts
@@ -97,7 +97,7 @@ export const TEST_IDS = {
addFeatureButton: "add-feature-button",
addFeatureDialog: "add-feature-dialog",
confirmAddFeature: "confirm-add-feature",
- featureBranchInput: "feature-branch-input",
+ featureBranchInput: "feature-input",
featureCategoryInput: "feature-category-input",
worktreeSelector: "worktree-selector",
diff --git a/apps/app/tests/utils/views/board.ts b/apps/app/tests/utils/views/board.ts
index 6295c77b..c1407357 100644
--- a/apps/app/tests/utils/views/board.ts
+++ b/apps/app/tests/utils/views/board.ts
@@ -137,8 +137,17 @@ export async function fillAddFeatureDialog(
// Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) {
- const branchButton = page.locator('[data-testid="feature-branch-input"]');
- await branchButton.click();
+ // First, select "Other branch" radio option if not already selected
+ const otherBranchRadio = page.locator('[data-testid="feature-radio-group"]').locator('[id="feature-other"]');
+ await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
+ await otherBranchRadio.click();
+ // Wait for the branch input to appear
+ await page.waitForTimeout(300);
+
+ // Now click on the branch input (autocomplete)
+ const branchInput = page.locator('[data-testid="feature-input"]');
+ await branchInput.waitFor({ state: "visible", timeout: 5000 });
+ await branchInput.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts
index f1eccbb9..c21228c4 100644
--- a/apps/app/tests/worktree-integration.spec.ts
+++ b/apps/app/tests/worktree-integration.spec.ts
@@ -741,14 +741,15 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
- // Create a worktree first
+ // Note: Worktrees are created at execution time (when feature starts),
+ // not when adding to backlog. We can specify a branch name without
+ // creating a worktree first.
const branchName = "feature/test-branch";
- await apiCreateWorktree(page, testRepo.path, branchName);
// Click add feature button
await clickAddFeature(page);
- // Fill in the feature details
+ // Fill in the feature details with a branch name
await fillAddFeatureDialog(page, "Test feature for worktree", {
branch: branchName,
category: "Testing",
@@ -773,9 +774,12 @@ test.describe("Worktree Integration Tests", () => {
expect(featureData.description).toBe("Test feature for worktree");
expect(featureData.branchName).toBe(branchName);
expect(featureData.status).toBe("backlog");
+ // Verify worktreePath is not set when adding to backlog
+ // (worktrees are created at execution time, not when adding to backlog)
+ expect(featureData.worktreePath).toBeUndefined();
});
- test("should create worktree automatically when adding feature with new branch", async ({
+ test("should store branch name when adding feature with new branch (worktree created at execution)", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
@@ -783,12 +787,13 @@ test.describe("Worktree Integration Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
- // Use a branch name that doesn't exist yet - NO worktree is pre-created
+ // Use a branch name that doesn't exist yet
+ // Note: Worktrees are now created at execution time, not when adding to backlog
const branchName = "feature/auto-create-worktree";
- const expectedWorktreePath = getWorktreePath(testRepo.path, branchName);
- // Verify worktree does NOT exist before we create the feature
- expect(fs.existsSync(expectedWorktreePath)).toBe(false);
+ // Verify branch does NOT exist before we create the feature
+ const branchesBefore = await listBranches(testRepo.path);
+ expect(branchesBefore).not.toContain(branchName);
// Click add feature button
await clickAddFeature(page);
@@ -802,17 +807,14 @@ test.describe("Worktree Integration Tests", () => {
// Confirm
await confirmAddFeature(page);
- // Wait for the worktree to be created
- await page.waitForTimeout(2000);
+ // Wait for feature to be saved
+ await page.waitForTimeout(1000);
- // Verify worktree was automatically created when feature was added
- expect(fs.existsSync(expectedWorktreePath)).toBe(true);
+ // Verify branch was NOT created when adding feature (created at execution time)
+ const branchesAfter = await listBranches(testRepo.path);
+ expect(branchesAfter).not.toContain(branchName);
- // Verify the branch was created
- const branches = await listBranches(testRepo.path);
- expect(branches).toContain(branchName);
-
- // Verify feature was created with correct branch
+ // Verify feature was created with correct branch name stored
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
expect(featureDirs.length).toBeGreaterThan(0);
@@ -829,8 +831,15 @@ test.describe("Worktree Integration Tests", () => {
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
+
+ // Verify branch name is stored
expect(featureData.branchName).toBe(branchName);
- expect(featureData.worktreePath).toBe(expectedWorktreePath);
+
+ // Verify worktreePath is NOT set (worktrees are created at execution time)
+ expect(featureData.worktreePath).toBeUndefined();
+
+ // Verify feature is in backlog status
+ expect(featureData.status).toBe("backlog");
});
test("should reset feature branch and worktree when worktree is deleted", async ({
@@ -887,8 +896,11 @@ test.describe("Worktree Integration Tests", () => {
let featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
+
+ // Verify feature was created with the branch name stored
expect(featureData.branchName).toBe(branchName);
- expect(featureData.worktreePath).toBe(worktreePath);
+ // Verify worktreePath is NOT set (worktrees are created at execution time, not when adding)
+ expect(featureData.worktreePath).toBeUndefined();
// Delete the worktree via UI
// Open the worktree actions menu
@@ -911,10 +923,11 @@ test.describe("Worktree Integration Tests", () => {
// Verify worktree is deleted
expect(fs.existsSync(worktreePath)).toBe(false);
- // Verify feature's branchName and worktreePath are reset to null
+ // Verify feature's branchName is reset to null/undefined when worktree is deleted
+ // (worktreePath was never stored, so it remains undefined)
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBeNull();
- expect(featureData.worktreePath).toBeNull();
+ expect(featureData.worktreePath).toBeUndefined();
// Verify the feature appears in the backlog when main is selected
const mainButton = page.getByRole("button", { name: "main" }).first();
@@ -940,8 +953,9 @@ test.describe("Worktree Integration Tests", () => {
await otherWorktreeButton.click();
await page.waitForTimeout(500);
- // Unassigned features should still be visible in the backlog
- await expect(featureText).toBeVisible({ timeout: 5000 });
+ // Unassigned features should NOT be visible on non-primary worktrees
+ // They should only show on the primary (main) worktree
+ await expect(featureText).not.toBeVisible({ timeout: 5000 });
});
test("should filter features by selected worktree", async ({ page }) => {
@@ -1062,9 +1076,11 @@ test.describe("Worktree Integration Tests", () => {
// Open add feature dialog
await clickAddFeature(page);
- // Verify the branch input button shows the selected worktree's branch
- const branchButton = page.locator('[data-testid="feature-branch-input"]');
- await expect(branchButton).toContainText(branchName, { timeout: 5000 });
+ // Verify the branch selector shows the selected worktree's branch
+ // When a worktree is selected, "Use current selected branch" should be selected
+ // and the branch name should be shown in the label
+ const currentBranchLabel = page.locator('label[for="feature-current"]');
+ await expect(currentBranchLabel).toContainText(branchName, { timeout: 5000 });
// Close dialog
await page.keyboard.press("Escape");
diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts
index 93253c47..eab3e9c1 100644
--- a/apps/server/src/routes/auto-mode/index.ts
+++ b/apps/server/src/routes/auto-mode/index.ts
@@ -6,8 +6,6 @@
import { Router } from "express";
import type { AutoModeService } from "../../services/auto-mode-service.js";
-import { createStartHandler } from "./routes/start.js";
-import { createStopHandler } from "./routes/stop.js";
import { createStopFeatureHandler } from "./routes/stop-feature.js";
import { createStatusHandler } from "./routes/status.js";
import { createRunFeatureHandler } from "./routes/run-feature.js";
@@ -21,8 +19,6 @@ import { createCommitFeatureHandler } from "./routes/commit-feature.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
- router.post("/start", createStartHandler(autoModeService));
- router.post("/stop", createStopHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));
diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts
index aa8887ad..1b470a25 100644
--- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts
+++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts
@@ -12,13 +12,14 @@ const logger = createLogger("AutoMode");
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise => {
try {
- const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as {
- projectPath: string;
- featureId: string;
- prompt: string;
- imagePaths?: string[];
- worktreePath?: string;
- };
+ const { projectPath, featureId, prompt, imagePaths, useWorktrees } =
+ req.body as {
+ projectPath: string;
+ featureId: string;
+ prompt: string;
+ imagePaths?: string[];
+ useWorktrees?: boolean;
+ };
if (!projectPath || !featureId || !prompt) {
res.status(400).json({
@@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
return;
}
- // Start follow-up in background, using the feature's worktreePath for correct branch
+ // Start follow-up in background
+ // followUpFeature derives workDir from feature.branchName
autoModeService
- .followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath)
+ .followUpFeature(
+ projectPath,
+ featureId,
+ prompt,
+ imagePaths,
+ useWorktrees ?? true
+ )
.catch((error) => {
logger.error(
`[AutoMode] Follow up feature ${featureId} error:`,
error
);
+ })
+ .finally(() => {
+ // Release the starting slot when follow-up completes (success or error)
+ // Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });
diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts
index 94f5b056..45410ba7 100644
--- a/apps/server/src/routes/auto-mode/routes/resume-feature.ts
+++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts
@@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
};
if (!projectPath || !featureId) {
- res
- .status(400)
- .json({
- success: false,
- error: "projectPath and featureId are required",
- });
+ res.status(400).json({
+ success: false,
+ error: "projectPath and featureId are required",
+ });
return;
}
@@ -34,7 +32,8 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) {
.resumeFeature(projectPath, featureId, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Resume feature ${featureId} error:`, error);
- });
+ })
+ .finally(() => {});
res.json({ success: true });
} catch (error) {
diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts
index da2f1f6c..bae005f3 100644
--- a/apps/server/src/routes/auto-mode/routes/run-feature.ts
+++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts
@@ -12,30 +12,30 @@ const logger = createLogger("AutoMode");
export function createRunFeatureHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise => {
try {
- const { projectPath, featureId, useWorktrees, worktreePath } = req.body as {
+ const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
- worktreePath?: string;
};
if (!projectPath || !featureId) {
- res
- .status(400)
- .json({
- success: false,
- error: "projectPath and featureId are required",
- });
+ res.status(400).json({
+ success: false,
+ error: "projectPath and featureId are required",
+ });
return;
}
// Start execution in background
- // If worktreePath is provided, use it directly; otherwise let the service decide
- // Default to false - worktrees should only be used when explicitly enabled
+ // executeFeature derives workDir from feature.branchName
autoModeService
- .executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath)
+ .executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`[AutoMode] Feature ${featureId} error:`, error);
+ })
+ .finally(() => {
+ // Release the starting slot when execution completes (success or error)
+ // Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });
diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts
deleted file mode 100644
index 9868cd1a..00000000
--- a/apps/server/src/routes/auto-mode/routes/start.ts
+++ /dev/null
@@ -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 => {
- 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) });
- }
- };
-}
diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts
deleted file mode 100644
index 69f21fc3..00000000
--- a/apps/server/src/routes/auto-mode/routes/stop.ts
+++ /dev/null
@@ -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 => {
- 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) });
- }
- };
-}
diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts
index 8d1f8760..e2f7e14e 100644
--- a/apps/server/src/routes/running-agents/routes/index.ts
+++ b/apps/server/src/routes/running-agents/routes/index.ts
@@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) {
success: true,
runningAgents,
totalCount: runningAgents.length,
- autoLoopRunning: status.autoLoopRunning,
});
} catch (error) {
logError(error, "Get running agents failed");
diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts
index 1a3477f0..ef749e9c 100644
--- a/apps/server/src/routes/worktree/routes/list.ts
+++ b/apps/server/src/routes/worktree/routes/list.ts
@@ -8,6 +8,7 @@
import type { Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
+import { existsSync } from "fs";
import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js";
const execAsync = promisify(exec);
@@ -58,10 +59,12 @@ export function createListHandler() {
});
const worktrees: WorktreeInfo[] = [];
+ const removedWorktrees: Array<{ path: string; branch: string }> = [];
const lines = stdout.split("\n");
let current: { path?: string; branch?: string } = {};
let isFirst = true;
+ // First pass: detect removed worktrees
for (const line of lines) {
if (line.startsWith("worktree ")) {
current.path = normalizePath(line.slice(9));
@@ -69,19 +72,40 @@ export function createListHandler() {
current.branch = line.slice(7).replace("refs/heads/", "");
} else if (line === "") {
if (current.path && current.branch) {
- worktrees.push({
- path: current.path,
- branch: current.branch,
- isMain: isFirst,
- isCurrent: current.branch === currentBranch,
- hasWorktree: true,
- });
- isFirst = false;
+ const isMainWorktree = isFirst;
+ // Check if the worktree directory actually exists
+ // Skip checking/pruning the main worktree (projectPath itself)
+ if (!isMainWorktree && !existsSync(current.path)) {
+ // Worktree directory doesn't exist - it was manually deleted
+ removedWorktrees.push({
+ path: current.path,
+ branch: current.branch,
+ });
+ } else {
+ // Worktree exists (or is main worktree), add it to the list
+ worktrees.push({
+ path: current.path,
+ branch: current.branch,
+ isMain: isMainWorktree,
+ isCurrent: current.branch === currentBranch,
+ hasWorktree: true,
+ });
+ isFirst = false;
+ }
}
current = {};
}
}
+ // Prune removed worktrees from git (only if any were detected)
+ if (removedWorktrees.length > 0) {
+ try {
+ await execAsync("git worktree prune", { cwd: projectPath });
+ } catch {
+ // Prune failed, but we'll still report the removed worktrees
+ }
+ }
+
// If includeDetails is requested, fetch change status for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
@@ -103,7 +127,11 @@ export function createListHandler() {
}
}
- res.json({ success: true, worktrees });
+ res.json({
+ success: true,
+ worktrees,
+ removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
+ });
} catch (error) {
logError(error, "List worktrees failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index 5c9f6785..12831a3d 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -20,14 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
-import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
-import {
- getFeatureDir,
- getFeaturesDir,
- getAutomakerDir,
- getWorktreesDir,
-} from "../lib/automaker-paths.js";
+import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
@@ -41,196 +35,43 @@ interface RunningFeature {
startTime: number;
}
-interface AutoModeConfig {
- maxConcurrency: number;
- useWorktrees: boolean;
- projectPath: string;
-}
-
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map();
- private autoLoopRunning = false;
- private autoLoopAbortController: AbortController | null = null;
- private config: AutoModeConfig | null = null;
constructor(events: EventEmitter) {
this.events = events;
}
- /**
- * Start the auto mode loop - continuously picks and executes pending features
- */
- async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise {
- 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 {
- 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 {
- 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
* @param projectPath - The main project path
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
- * @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
- isAutoMode = false,
- providedWorktreePath?: string
+ isAutoMode = false
): Promise {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
- const abortController = new AbortController();
- const branchName = `feature/${featureId}`;
- let worktreePath: string | null = null;
-
- // Use provided worktree path if given, otherwise setup new worktree if enabled
- if (providedWorktreePath) {
- // Resolve to absolute path - critical for cross-platform compatibility
- // On Windows, relative paths or paths with forward slashes may not work correctly with cwd
- // On all platforms, absolute paths ensure commands execute in the correct directory
- try {
- // Resolve relative paths relative to projectPath, absolute paths as-is
- const resolvedPath = path.isAbsolute(providedWorktreePath)
- ? path.resolve(providedWorktreePath)
- : path.resolve(projectPath, providedWorktreePath);
-
- // Verify the path exists before using it
- await fs.access(resolvedPath);
- worktreePath = resolvedPath;
- console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
- } catch (error) {
- console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
- // Fall through to create new worktree or use project path
- }
- }
-
- if (!worktreePath && useWorktrees) {
- // No specific worktree provided, create a new one for this feature
- worktreePath = await this.setupWorktree(
- projectPath,
- featureId,
- branchName
+ // Check if feature has existing context - if so, resume instead of starting fresh
+ const hasExistingContext = await this.contextExists(projectPath, featureId);
+ if (hasExistingContext) {
+ console.log(
+ `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
);
+ return this.resumeFeature(projectPath, featureId, useWorktrees);
}
- // Ensure workDir is always an absolute path for cross-platform compatibility
- const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
+ const abortController = new AbortController();
- this.runningFeatures.set(featureId, {
- featureId,
- projectPath,
- worktreePath,
- branchName,
- abortController,
- isAutoMode,
- startTime: Date.now(),
- });
-
- // Emit feature start event
+ // Emit feature start event early
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
@@ -242,12 +83,53 @@ export class AutoModeService {
});
try {
- // Load feature details
+ // Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
+ // Derive workDir from feature.branchName
+ // If no branchName, 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
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -262,7 +144,7 @@ export class AutoModeService {
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
- `[AutoMode] Executing feature ${featureId} with model: ${model}`
+ `[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with the feature's model and images
@@ -271,6 +153,7 @@ export class AutoModeService {
featureId,
prompt,
abortController,
+ projectPath,
imagePaths,
model
);
@@ -371,7 +254,7 @@ export class AutoModeService {
featureId: string,
prompt: string,
imagePaths?: string[],
- providedWorktreePath?: string
+ useWorktrees = true
): Promise {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -379,32 +262,29 @@ export class AutoModeService {
const abortController = new AbortController();
- // Use the provided worktreePath (from the feature's assigned branch)
- // Fall back to project path if not provided
+ // Load feature info for context FIRST to get branchName
+ const feature = await this.loadFeature(projectPath, featureId);
+
+ // Derive workDir from feature.branchName
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
+ const branchName = feature?.branchName || null;
- if (providedWorktreePath) {
- try {
- // Resolve to absolute path - critical for cross-platform compatibility
- // On Windows, relative paths or paths with forward slashes may not work correctly with cwd
- // On all platforms, absolute paths ensure commands execute in the correct directory
- const resolvedPath = path.isAbsolute(providedWorktreePath)
- ? path.resolve(providedWorktreePath)
- : path.resolve(projectPath, providedWorktreePath);
-
- await fs.access(resolvedPath);
- workDir = resolvedPath;
- worktreePath = resolvedPath;
- } catch {
- // Worktree path provided but doesn't exist, use project path
- console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
+ if (useWorktrees && branchName) {
+ // Try to find existing worktree for this branch
+ worktreePath = await this.findExistingWorktreeForBranch(
+ projectPath,
+ branchName
+ );
+
+ if (worktreePath) {
+ workDir = worktreePath;
+ console.log(
+ `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`
+ );
}
}
- // Load feature info for context
- const feature = await this.loadFeature(projectPath, featureId);
-
// Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -441,7 +321,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
projectPath,
worktreePath,
- branchName: worktreePath ? path.basename(worktreePath) : null,
+ branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -537,6 +417,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
fullPrompt,
abortController,
+ projectPath,
allImagePaths.length > 0 ? allImagePaths : imagePaths,
model,
previousContext || undefined
@@ -653,17 +534,25 @@ Address the follow-up instructions above. Review the previous work and make the
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
- console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
+ console.log(
+ `[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`
+ );
}
} else {
// Fallback: try to find worktree at legacy location
- const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
+ const legacyWorktreePath = path.join(
+ projectPath,
+ ".worktrees",
+ featureId
+ );
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
- console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
+ console.log(
+ `[AutoMode] No worktree found, committing in project path: ${workDir}`
+ );
}
}
@@ -816,13 +705,11 @@ Format your response as a structured markdown document.`;
*/
getStatus(): {
isRunning: boolean;
- autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
- isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
- autoLoopRunning: this.autoLoopRunning,
+ isRunning: this.runningFeatures.size > 0,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
@@ -905,10 +792,15 @@ Format your response as a structured markdown document.`;
branchName: string
): Promise {
// First, check if git already has a worktree for this branch (anywhere)
- const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
+ const existingWorktree = await this.findExistingWorktreeForBranch(
+ projectPath,
+ branchName
+ );
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
- console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
+ console.log(
+ `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
+ );
return existingWorktree;
}
@@ -992,56 +884,6 @@ Format your response as a structured markdown document.`;
}
}
- private async loadPendingFeatures(projectPath: string): Promise {
- // 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)
*/
@@ -1060,31 +902,6 @@ Format your response as a structured markdown document.`;
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
- | 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 {
const title = this.extractTitleFromDescription(feature.description);
@@ -1164,6 +981,7 @@ This helps parse your summary correctly in the output logs.`;
featureId: string,
prompt: string,
abortController: AbortController,
+ projectPath: string,
imagePaths?: string[],
model?: 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
// This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") {
- console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
+ console.log(
+ `[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`
+ );
// Simulate some work being done
await this.sleep(500);
@@ -1203,8 +1023,7 @@ This helps parse your summary correctly in the output logs.`;
await this.sleep(200);
// Save mock agent output
- const configProjectPath = this.config?.projectPath || workDir;
- const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
+ const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
@@ -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.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;
}
@@ -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`
: "";
// Agent output goes to .automaker directory
- // Note: We use the original projectPath here (from config), not workDir
- // because workDir might be a worktree path
- const configProjectPath = this.config?.projectPath || workDir;
- const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
+ // Note: We use projectPath here, not workDir, because workDir might be a worktree path
+ const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
@@ -1290,7 +1109,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
- console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
+ console.error(
+ `[AutoMode] Failed to write agent output for ${featureId}:`,
+ error
+ );
}
};
@@ -1309,11 +1131,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
- if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
- if (responseText.endsWith('\n')) {
- responseText += '\n';
+ if (responseText.length > 0 && !responseText.endsWith("\n\n")) {
+ if (responseText.endsWith("\n")) {
+ responseText += "\n";
} else {
- responseText += '\n\n';
+ responseText += "\n\n";
}
}
responseText += block.text || "";
@@ -1347,12 +1169,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
});
// Also add to file output for persistence
- if (responseText.length > 0 && !responseText.endsWith('\n')) {
- responseText += '\n';
+ if (responseText.length > 0 && !responseText.endsWith("\n")) {
+ responseText += "\n";
}
responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) {
- responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
+ responseText += `Input: ${JSON.stringify(
+ block.input,
+ null,
+ 2
+ )}\n`;
}
scheduleWrite();
}
@@ -1382,12 +1208,68 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
context: string,
useWorktrees: boolean
): Promise {
- const feature = await this.loadFeature(projectPath, featureId);
- if (!feature) {
- throw new Error(`Feature ${featureId} not found`);
+ if (this.runningFeatures.has(featureId)) {
+ throw new Error(`Feature ${featureId} is already running`);
}
- 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)}
@@ -1399,7 +1281,67 @@ ${context}
## Instructions
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 {
- return new Promise((resolve) => setTimeout(resolve, ms));
+ private sleep(ms: number, signal?: AbortSignal): Promise {
+ 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 }
+ );
+ }
+ });
}
}
diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts
index 67850f99..a8e55256 100644
--- a/apps/server/src/services/feature-loader.ts
+++ b/apps/server/src/services/feature-loader.ts
@@ -24,6 +24,8 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array;
+ // Branch info - worktree path is derived at runtime from branchName
+ branchName?: string; // Name of the feature branch (undefined = use current worktree)
[key: string]: unknown;
}
diff --git a/package-lock.json b/package-lock.json
index ed4a9293..c12edb1c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,8 +33,10 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
@@ -1546,10 +1548,6 @@
"version": "1.1.1",
"license": "MIT"
},
- "apps/app/node_modules/@radix-ui/primitive": {
- "version": "1.1.3",
- "license": "MIT"
- },
"apps/app/node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"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": {
"version": "1.1.15",
"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": {
"version": "1.1.11",
"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": {
"version": "2.1.8",
"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": {
"version": "1.3.6",
"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": {
"version": "1.1.1",
"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": {
"version": "1.1.1",
"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": {
"version": "1.2.3",
"license": "MIT",
@@ -11195,6 +10922,358 @@
"dev": true,
"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": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",