diff --git a/apps/app/package.json b/apps/app/package.json
index 528d6613..c1114d89 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -45,9 +45,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts
index 26f06499..e01c9bbc 100644
--- a/apps/app/playwright.config.ts
+++ b/apps/app/playwright.config.ts
@@ -3,14 +3,15 @@ import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
-const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
+const mockAgent =
+ process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
- workers: process.env.CI ? 1 : undefined,
+ workers: undefined,
reporter: "html",
timeout: 30000,
use: {
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/dialog.tsx b/apps/app/src/components/ui/dialog.tsx
index ca028e21..79d012fa 100644
--- a/apps/app/src/components/ui/dialog.tsx
+++ b/apps/app/src/components/ui/dialog.tsx
@@ -87,16 +87,18 @@ function DialogOverlay({
);
}
-function DialogContent({
- className,
- children,
- showCloseButton = true,
- compact = false,
- ...props
-}: React.ComponentProps & {
+export type DialogContentProps = Omit<
+ React.ComponentProps,
+ "ref"
+> & {
showCloseButton?: boolean;
compact?: boolean;
-}) {
+};
+
+const DialogContent = React.forwardRef<
+ HTMLDivElement,
+ DialogContentProps
+>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
@@ -105,6 +107,7 @@ function DialogContent({
);
-}
+});
+
+DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
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..3377f6dc
--- /dev/null
+++ b/apps/app/src/components/ui/radio-group.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import * as React from "react";
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { Circle } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ 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..47e4151a
--- /dev/null
+++ b/apps/app/src/components/ui/switch.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ 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 ee29646f..ce86ea26 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,
@@ -76,6 +78,10 @@ export function BoardView() {
setCurrentWorktree,
getWorktrees,
setWorktrees,
+ useWorktrees,
+ enableDependencyBlocking,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -93,7 +99,7 @@ export function BoardView() {
const [featuresWithContext, setFeaturesWithContext] = useState>(
new Set()
);
- const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
+ const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
@@ -285,6 +291,27 @@ export function BoardView() {
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
+ // Memoize the removed worktrees handler to prevent infinite loops
+ const handleRemovedWorktrees = useCallback(
+ (removedWorktrees: Array<{ path: string; branch: string }>) => {
+ // Reset features that were assigned to the removed worktrees (by branch)
+ hookFeatures.forEach((feature) => {
+ const matchesRemovedWorktree = removedWorktrees.some((removed) => {
+ // Match by branch name since worktreePath is no longer stored
+ return feature.branchName === removed.branch;
+ });
+
+ if (matchesRemovedWorktree) {
+ // Reset the feature's branch assignment
+ persistFeatureUpdate(feature.id, {
+ branchName: null as unknown as string | undefined,
+ });
+ }
+ });
+ },
+ [hookFeatures, persistFeatureUpdate]
+ );
+
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
@@ -293,13 +320,12 @@ export function BoardView() {
});
}, [hookFeatures, runningAutoTasks]);
- // Get current worktree info (path and branch) for filtering features
+ // Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
- const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
@@ -309,8 +335,25 @@ export function BoardView() {
[currentProject, worktreesByProject]
);
+ // Get the branch for the currently selected worktree
+ // Find the worktree that matches the current selection, or use main worktree
+ const selectedWorktree = useMemo(() => {
+ if (currentWorktreePath === null) {
+ // Primary worktree selected - find the main worktree
+ return worktrees.find((w) => w.isMain);
+ } else {
+ // Specific worktree selected - find it by path
+ return worktrees.find(
+ (w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
+ );
+ }
+ }, [worktrees, currentWorktreePath]);
+
+ // Get the current branch from the selected worktree (not from store which may be stale)
+ const currentWorktreeBranch = selectedWorktree?.branch ?? null;
+
// Get the branch for the currently selected worktree (for defaulting new features)
- // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch
+ // Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
@@ -334,7 +377,7 @@ export function BoardView() {
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
- handleDeleteAllVerified,
+ handleArchiveAllVerified,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -362,6 +405,205 @@ export function BoardView() {
currentWorktreeBranch,
});
+ // Client-side auto mode: periodically check for backlog items and move them to in-progress
+ // Use a ref to track the latest auto mode state so async operations always check the current value
+ const autoModeRunningRef = useRef(autoMode.isRunning);
+ useEffect(() => {
+ autoModeRunningRef.current = autoMode.isRunning;
+ }, [autoMode.isRunning]);
+
+ // Use a ref to track the latest features to avoid effect re-runs when features change
+ const hookFeaturesRef = useRef(hookFeatures);
+ useEffect(() => {
+ hookFeaturesRef.current = hookFeatures;
+ }, [hookFeatures]);
+
+ // Track features that are pending (started but not yet confirmed running)
+ const pendingFeaturesRef = useRef>(new Set());
+
+ // Listen to auto mode events to remove features from pending when they start running
+ useEffect(() => {
+ const api = getElectronAPI();
+ if (!api?.autoMode) return;
+
+ const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
+ if (!currentProject) return;
+
+ // Only process events for the current project
+ const eventProjectPath =
+ "projectPath" in event ? event.projectPath : undefined;
+ if (eventProjectPath && eventProjectPath !== currentProject.path) {
+ return;
+ }
+
+ switch (event.type) {
+ case "auto_mode_feature_start":
+ // Feature is now confirmed running - remove from pending
+ if (event.featureId) {
+ pendingFeaturesRef.current.delete(event.featureId);
+ }
+ break;
+
+ case "auto_mode_feature_complete":
+ case "auto_mode_error":
+ // Feature completed or errored - remove from pending if still there
+ if (event.featureId) {
+ pendingFeaturesRef.current.delete(event.featureId);
+ }
+ break;
+ }
+ });
+
+ return unsubscribe;
+ }, [currentProject]);
+
+ useEffect(() => {
+ if (!autoMode.isRunning || !currentProject) {
+ return;
+ }
+
+ let isChecking = false;
+ let isActive = true; // Track if this effect is still active
+
+ const checkAndStartFeatures = async () => {
+ // Check if auto mode is still running and effect is still active
+ // Use ref to get the latest value, not the closure value
+ if (!isActive || !autoModeRunningRef.current || !currentProject) {
+ return;
+ }
+
+ // Prevent concurrent executions
+ if (isChecking) {
+ return;
+ }
+
+ isChecking = true;
+ try {
+ // Double-check auto mode is still running before proceeding
+ if (!isActive || !autoModeRunningRef.current || !currentProject) {
+ return;
+ }
+
+ // Count currently running tasks + pending features
+ const currentRunning =
+ runningAutoTasks.length + pendingFeaturesRef.current.size;
+ const availableSlots = maxConcurrency - currentRunning;
+
+ // No available slots, skip check
+ if (availableSlots <= 0) {
+ return;
+ }
+
+ // Filter backlog features by the currently selected worktree branch
+ // This logic mirrors use-board-column-features.ts for consistency
+ // Use ref to get the latest features without causing effect re-runs
+ const currentFeatures = hookFeaturesRef.current;
+ const backlogFeatures = currentFeatures.filter((f) => {
+ if (f.status !== "backlog") return false;
+
+ const featureBranch = f.branchName;
+
+ // Features without branchName are considered unassigned (show only on primary worktree)
+ if (!featureBranch) {
+ // No branch assigned - show only when viewing primary worktree
+ const isViewingPrimary = currentWorktreePath === null;
+ return isViewingPrimary;
+ }
+
+ if (currentWorktreeBranch === null) {
+ // We're viewing main but branch hasn't been initialized yet
+ // Show features assigned to primary worktree's branch
+ return currentProject.path
+ ? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
+ : false;
+ }
+
+ // Match by branch name
+ return featureBranch === currentWorktreeBranch;
+ });
+
+ if (backlogFeatures.length === 0) {
+ return;
+ }
+
+ // Sort by priority (lower number = higher priority, priority 1 is highest)
+ const sortedBacklog = [...backlogFeatures].sort(
+ (a, b) => (a.priority || 999) - (b.priority || 999)
+ );
+
+ // Filter out features with blocking dependencies if dependency blocking is enabled
+ const eligibleFeatures = enableDependencyBlocking
+ ? sortedBacklog.filter((f) => {
+ const blockingDeps = getBlockingDependencies(f, currentFeatures);
+ return blockingDeps.length === 0;
+ })
+ : sortedBacklog;
+
+ // Start features up to available slots
+ const featuresToStart = eligibleFeatures.slice(0, availableSlots);
+
+ for (const feature of featuresToStart) {
+ // Check again before starting each feature
+ if (!isActive || !autoModeRunningRef.current || !currentProject) {
+ return;
+ }
+
+ // Simplified: No worktree creation on client - server derives workDir from feature.branchName
+ // If feature has no branchName and primary worktree is selected, assign primary branch
+ if (currentWorktreePath === null && !feature.branchName) {
+ const primaryBranch =
+ (currentProject.path
+ ? getPrimaryWorktreeBranch(currentProject.path)
+ : null) || "main";
+ await persistFeatureUpdate(feature.id, {
+ branchName: primaryBranch,
+ });
+ }
+
+ // Final check before starting implementation
+ if (!isActive || !autoModeRunningRef.current || !currentProject) {
+ return;
+ }
+
+ // Start the implementation - server will derive workDir from feature.branchName
+ const started = await handleStartImplementation(feature);
+
+ // If successfully started, track it as pending until we receive the start event
+ if (started) {
+ pendingFeaturesRef.current.add(feature.id);
+ }
+ }
+ } finally {
+ isChecking = false;
+ }
+ };
+
+ // Check immediately, then every 3 seconds
+ checkAndStartFeatures();
+ const interval = setInterval(checkAndStartFeatures, 3000);
+
+ return () => {
+ // Mark as inactive to prevent any pending async operations from continuing
+ isActive = false;
+ clearInterval(interval);
+ // Clear pending features when effect unmounts or dependencies change
+ pendingFeaturesRef.current.clear();
+ };
+ }, [
+ autoMode.isRunning,
+ currentProject,
+ runningAutoTasks,
+ maxConcurrency,
+ // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
+ currentWorktreeBranch,
+ currentWorktreePath,
+ getPrimaryWorktreeBranch,
+ isPrimaryWorktreeBranch,
+ enableDependencyBlocking,
+ persistFeatureUpdate,
+ handleStartImplementation,
+ ]);
+
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -378,8 +620,6 @@ export function BoardView() {
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
- projectPath: currentProject?.path || null,
- onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use column features hook
@@ -554,8 +794,13 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
- onStartAutoMode={() => autoMode.start()}
- onStopAutoMode={() => autoMode.stop()}
+ onAutoModeToggle={(enabled) => {
+ if (enabled) {
+ autoMode.start();
+ } else {
+ autoMode.stop();
+ }
+ }}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
@@ -586,10 +831,10 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
+ onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map((f) => ({
id: f.id,
- worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
@@ -646,7 +891,7 @@ export function BoardView() {
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
- onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)}
+ onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
/>
@@ -686,6 +931,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
+ currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -698,6 +944,7 @@ export function BoardView() {
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
+ currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -714,14 +961,14 @@ export function BoardView() {
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
- {/* Delete All Verified Dialog */}
- {
- await handleDeleteAllVerified();
- setShowDeleteAllVerifiedDialog(false);
+ await handleArchiveAllVerified();
+ setShowArchiveAllVerifiedDialog(false);
}}
/>
@@ -819,19 +1066,13 @@ export function BoardView() {
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={(deletedWorktree, _deletedBranch) => {
- // Reset features that were assigned to the deleted worktree
+ // Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
- const matchesByPath =
- feature.worktreePath &&
- pathsEqual(feature.worktreePath, deletedWorktree.path);
- const matchesByBranch =
- feature.branchName === deletedWorktree.branch;
-
- if (matchesByPath || matchesByBranch) {
- // Reset the feature's worktree assignment
+ // Match by branch name since worktreePath is no longer stored
+ if (feature.branchName === deletedWorktree.branch) {
+ // Reset the feature's branch assignment
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
- worktreePath: null as unknown as string | undefined,
});
}
});
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 ? (
-
- ) : (
-
- )}
- >
+
+
+
+
)}
({
...prev,
skipTests: defaultSkipTests,
- branchName: defaultBranch,
+ branchName: defaultBranch || "",
}));
+ setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
@@ -137,12 +141,25 @@ export function AddFeatureDialog({
return;
}
+ // Validate branch selection when "other branch" is selected
+ if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
+ toast.error("Please select a branch name");
+ return;
+ }
+
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
+ // Use current branch if toggle is on
+ // If currentBranch is provided (non-primary worktree), use it
+ // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
+ const finalBranchName = useCurrentBranch
+ ? (currentBranch || "")
+ : newFeature.branchName || "";
+
onAdd({
category,
description: newFeature.description,
@@ -152,7 +169,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
- branchName: newFeature.branchName,
+ branchName: finalBranchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
@@ -169,8 +186,9 @@ export function AddFeatureDialog({
model: "opus",
priority: 2,
thinkingLevel: "none",
- branchName: defaultBranch,
+ branchName: "",
});
+ setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
@@ -372,22 +390,17 @@ export function AddFeatureDialog({
/>
{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 */}
@@ -501,6 +514,11 @@ export function AddFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
+ disabled={
+ useWorktrees &&
+ !useCurrentBranch &&
+ !newFeature.branchName.trim()
+ }
>
Add Feature
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..cb6d2f0d
--- /dev/null
+++ b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Archive } from "lucide-react";
+
+interface ArchiveAllVerifiedDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ verifiedCount: number;
+ onConfirm: () => void;
+}
+
+export function ArchiveAllVerifiedDialog({
+ open,
+ onOpenChange,
+ verifiedCount,
+ onConfirm,
+}: ArchiveAllVerifiedDialogProps) {
+ return (
+
+ );
+}
+
+
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..dd5dd344 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
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
@@ -49,18 +49,26 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState(null);
const [browserUrl, setBrowserUrl] = useState(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
+ // Track whether an operation completed that warrants a refresh
+ const operationCompletedRef = useRef(false);
// Reset state when dialog opens or worktree changes
useEffect(() => {
if (open) {
- // Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback)
- // These are set by the API response and should persist until dialog closes
+ // Reset form fields
setTitle("");
setBody("");
setCommitMessage("");
setBaseBranch("main");
setIsDraft(false);
setError(null);
+ // Also reset result states when opening for a new worktree
+ // This prevents showing stale PR URLs from previous worktrees
+ setPrUrl(null);
+ setBrowserUrl(null);
+ setShowBrowserFallback(false);
+ // Reset operation tracking
+ operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle("");
@@ -72,6 +80,7 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
+ operationCompletedRef.current = false;
}
}, [open, worktree?.path]);
@@ -98,6 +107,8 @@ export function CreatePRDialog({
if (result.success && result.result) {
if (result.result.prCreated && result.result.prUrl) {
setPrUrl(result.result.prUrl);
+ // Mark operation as completed for refresh on close
+ operationCompletedRef.current = true;
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
@@ -105,7 +116,8 @@ export function CreatePRDialog({
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
- onCreated();
+ // Don't call onCreated() here - keep dialog open to show success message
+ // onCreated() will be called when user closes the dialog
} else {
// Branch was pushed successfully
const prError = result.result.prError;
@@ -117,6 +129,8 @@ export function CreatePRDialog({
if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) {
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
+ // Mark operation as completed - branch was pushed successfully
+ operationCompletedRef.current = true;
toast.success("Branch pushed", {
description: result.result.committed
? `Commit ${result.result.commitHash} pushed to ${result.result.branch}`
@@ -142,6 +156,8 @@ export function CreatePRDialog({
// Show error but also provide browser option
setBrowserUrl(result.result.browserUrl ?? null);
setShowBrowserFallback(true);
+ // Mark operation as completed - branch was pushed even though PR creation failed
+ operationCompletedRef.current = true;
toast.error("PR creation failed", {
description: errorMessage,
duration: 8000,
@@ -182,19 +198,13 @@ export function CreatePRDialog({
};
const handleClose = () => {
+ // Only call onCreated() if an actual operation completed
+ // This prevents unnecessary refreshes when user cancels
+ if (operationCompletedRef.current) {
+ onCreated();
+ }
onOpenChange(false);
- // Reset state after dialog closes
- setTimeout(() => {
- setTitle("");
- setBody("");
- setCommitMessage("");
- setBaseBranch("main");
- setIsDraft(false);
- setError(null);
- setPrUrl(null);
- setBrowserUrl(null);
- setShowBrowserFallback(false);
- }, 200);
+ // State reset is handled by useEffect when open becomes false
};
if (!worktree) return null;
@@ -228,13 +238,18 @@ export function CreatePRDialog({
Your PR is ready for review
-
+
+
+
+
) : 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 4c60cbba..56d2757b 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
@@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
-import { BranchAutocomplete } from "@/components/ui/branch-autocomplete";
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
@@ -46,6 +45,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
+ BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
@@ -69,7 +69,7 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
- branchName: string;
+ branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
@@ -77,6 +77,7 @@ interface EditFeatureDialogProps {
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
+ currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -89,12 +90,17 @@ export function EditFeatureDialog({
onUpdate,
categorySuggestions,
branchSuggestions,
+ currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState(feature);
+ 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);
@@ -114,6 +120,8 @@ export function EditFeatureDialog({
if (feature) {
setPlanningMode(feature.planningMode ?? 'skip');
setRequirePlanApproval(feature.requirePlanApproval ?? false);
+ // If feature has no branchName, default to using current branch
+ setUseCurrentBranch(!feature.branchName);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
@@ -123,6 +131,18 @@ export function EditFeatureDialog({
const handleUpdate = () => {
if (!editingFeature) return;
+ // Validate branch selection when "other branch" is selected and branch selector is enabled
+ const isBranchSelectorEnabled = editingFeature.status === "backlog";
+ if (
+ useWorktrees &&
+ isBranchSelectorEnabled &&
+ !useCurrentBranch &&
+ !editingFeature.branchName?.trim()
+ ) {
+ toast.error("Please select a branch name");
+ return;
+ }
+
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
@@ -130,6 +150,13 @@ export function EditFeatureDialog({
? editingFeature.thinkingLevel ?? "none"
: "none";
+ // Use current branch if toggle is on
+ // If currentBranch is provided (non-primary worktree), use it
+ // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
+ const finalBranchName = useCurrentBranch
+ ? (currentBranch || "")
+ : editingFeature.branchName || "";
+
const updates = {
category: editingFeature.category,
description: editingFeature.description,
@@ -138,7 +165,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
- branchName: editingFeature.branchName ?? "main",
+ branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
@@ -351,33 +378,21 @@ export function EditFeatureDialog({
/>
{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 */}
@@ -509,6 +524,12 @@ export function EditFeatureDialog({
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
+ disabled={
+ useWorktrees &&
+ editingFeature.status === "backlog" &&
+ !useCurrentBranch &&
+ !editingFeature.branchName?.trim()
+ }
>
Save Changes
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 a97de8e2..52538d4e 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 f7d80e28..9deb8a40 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
@@ -77,67 +77,13 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
} = useAppStore();
const autoMode = useAutoMode();
- /**
- * Get or create the worktree path for a feature based on its branchName.
- * - If branchName is "main" or empty, returns the project path
- * - Otherwise, creates a worktree for that branch if needed
- */
- const getOrCreateWorktreeForFeature = useCallback(
- async (feature: Feature): Promise => {
- if (!projectPath) return null;
-
- const branchName = feature.branchName || "main";
-
- // If targeting main branch, use the project path directly
- if (branchName === "main" || branchName === "master") {
- return projectPath;
- }
-
- // For other branches, create a worktree if it doesn't exist
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.create) {
- console.error("[BoardActions] Worktree API not available");
- return projectPath;
- }
-
- // Try to create the worktree (will return existing if already exists)
- const result = await api.worktree.create(projectPath, branchName);
-
- if (result.success && result.worktree) {
- console.log(
- `[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}`
- );
- if (result.worktree.isNew) {
- toast.success(`Worktree created for branch "${branchName}"`, {
- description: "A new worktree was created for this feature.",
- });
- }
- return result.worktree.path;
- } else {
- console.error(
- "[BoardActions] Failed to create worktree:",
- result.error
- );
- toast.error("Failed to create worktree", {
- description:
- result.error || "Could not create worktree for this branch.",
- });
- return projectPath; // Fall back to project path
- }
- } catch (error) {
- console.error("[BoardActions] Error creating worktree:", error);
- toast.error("Error creating worktree", {
- description: error instanceof Error ? error.message : "Unknown error",
- });
- return projectPath; // Fall back to project path
- }
- },
- [projectPath]
- );
+ // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
+ // at execution time based on feature.branchName
const handleAddFeature = useCallback(
async (featureData: {
@@ -154,34 +100,24 @@ export function useBoardActions({
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => {
- let worktreePath: string | undefined;
-
- // If worktrees are enabled and a non-main branch is selected, create the worktree
- if (useWorktrees && featureData.branchName) {
- const branchName = featureData.branchName;
- if (branchName !== "main" && branchName !== "master") {
- // Create a temporary feature-like object for getOrCreateWorktreeForFeature
- const tempFeature = { branchName } as Feature;
- const path = await getOrCreateWorktreeForFeature(tempFeature);
- if (path && path !== projectPath) {
- worktreePath = path;
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- }
- }
+ // Simplified: Only store branchName, no worktree creation on add
+ // Worktrees are created at execution time (when feature starts)
+ // Empty string means "unassigned" (show only on primary worktree) - convert to undefined
+ // Non-empty string is the actual branch name (for non-primary worktrees)
+ const finalBranchName = featureData.branchName || undefined;
const newFeatureData = {
...featureData,
status: "backlog" as const,
- worktreePath,
+ branchName: finalBranchName,
+ // No worktreePath - derived at runtime from branchName
};
const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
- [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
+ [addFeature, persistFeatureCreate, saveCategory]
);
const handleUpdateFeature = useCallback(
@@ -201,44 +137,12 @@ export function useBoardActions({
requirePlanApproval?: boolean;
}
) => {
- // Get the current feature to check if branch is changing
- const currentFeature = features.find((f) => f.id === featureId);
- const currentBranch = currentFeature?.branchName || "main";
- const newBranch = updates.branchName || "main";
- const branchIsChanging = currentBranch !== newBranch;
+ const finalBranchName = updates.branchName || undefined;
- let worktreePath: string | undefined;
- let shouldClearWorktreePath = false;
-
- // If worktrees are enabled and branch is changing to a non-main branch, create worktree
- if (useWorktrees && branchIsChanging) {
- if (newBranch === "main" || newBranch === "master") {
- // Changing to main - clear the worktreePath
- shouldClearWorktreePath = true;
- } else {
- // Changing to a feature branch - create worktree if needed
- const tempFeature = { branchName: newBranch } as Feature;
- const path = await getOrCreateWorktreeForFeature(tempFeature);
- if (path && path !== projectPath) {
- worktreePath = path;
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- }
- }
-
- // Build final updates with worktreePath if it was changed
- let finalUpdates: typeof updates & { worktreePath?: string };
- if (branchIsChanging && useWorktrees) {
- if (shouldClearWorktreePath) {
- // Use null to clear the value in persistence (cast to work around type system)
- finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined };
- } else {
- finalUpdates = { ...updates, worktreePath };
- }
- } else {
- finalUpdates = updates;
- }
+ const finalUpdates = {
+ ...updates,
+ branchName: finalBranchName,
+ };
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
@@ -247,7 +151,7 @@ export function useBoardActions({
}
setEditingFeature(null);
},
- [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
+ [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
);
const handleDeleteFeature = useCallback(
@@ -312,21 +216,18 @@ export function useBoardActions({
return;
}
- // Use the feature's assigned worktreePath (set when moving to in_progress)
- // This ensures work happens in the correct worktree based on the feature's branchName
- const featureWorktreePath = feature.worktreePath;
-
+ // Server derives workDir from feature.branchName at execution time
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
- useWorktrees,
- featureWorktreePath || undefined
+ useWorktrees
+ // No worktreePath - server derives from feature.branchName
);
if (result.success) {
console.log(
- "[Board] Feature run started successfully in worktree:",
- featureWorktreePath || "main"
+ "[Board] Feature run started successfully, branch:",
+ feature.branchName || "default"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
@@ -355,10 +256,12 @@ export function useBoardActions({
if (enableDependencyBlocking) {
const blockingDeps = getBlockingDependencies(feature, features);
if (blockingDeps.length > 0) {
- const depDescriptions = blockingDeps.map(depId => {
- const dep = features.find(f => f.id === depId);
- return dep ? truncateDescription(dep.description, 40) : depId;
- }).join(", ");
+ const depDescriptions = blockingDeps
+ .map((depId) => {
+ const dep = features.find((f) => f.id === depId);
+ return dep ? truncateDescription(dep.description, 40) : depId;
+ })
+ .join(", ");
toast.warning("Starting feature with incomplete dependencies", {
description: `This feature depends on: ${depDescriptions}`,
@@ -377,7 +280,14 @@ export function useBoardActions({
await handleRunFeature(feature);
return true;
},
- [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
+ [
+ autoMode,
+ enableDependencyBlocking,
+ features,
+ updateFeature,
+ persistFeatureUpdate,
+ handleRunFeature,
+ ]
);
const handleVerifyFeature = useCallback(
@@ -494,7 +404,6 @@ export function useBoardActions({
const featureId = followUpFeature.id;
const featureDescription = followUpFeature.description;
- const prompt = followUpPrompt;
const api = getElectronAPI();
if (!api?.autoMode?.followUpFeature) {
@@ -526,15 +435,14 @@ export function useBoardActions({
});
const imagePaths = followUpImagePaths.map((img) => img.path);
- // Use the feature's worktreePath to ensure work happens in the correct branch
- const featureWorktreePath = followUpFeature.worktreePath;
+ // Server derives workDir from feature.branchName at execution time
api.autoMode
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
- imagePaths,
- featureWorktreePath
+ imagePaths
+ // No worktreePath - server derives from feature.branchName
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
@@ -574,11 +482,11 @@ export function useBoardActions({
return;
}
- // Pass the feature's worktreePath to ensure commits happen in the correct worktree
+ // Server derives workDir from feature.branchName
const result = await api.autoMode.commitFeature(
currentProject.path,
- feature.id,
- feature.worktreePath
+ feature.id
+ // No worktreePath - server derives from feature.branchName
);
if (result.success) {
@@ -763,23 +671,25 @@ export function useBoardActions({
const handleStartNextFeatures = useCallback(async () => {
// Filter backlog features by the currently selected worktree branch
// This ensures "G" only starts features from the filtered list
+ const primaryBranch = projectPath
+ ? getPrimaryWorktreeBranch(projectPath)
+ : null;
const backlogFeatures = features.filter((f) => {
if (f.status !== "backlog") return false;
- // Determine the feature's branch (default to "main" if not set)
- const featureBranch = f.branchName || "main";
+ // Determine the feature's branch (default to primary branch if not set)
+ const featureBranch = f.branchName || primaryBranch || "main";
- // If no worktree is selected (currentWorktreeBranch is null or main-like),
- // show features with no branch or "main"/"master" branch
+ // If no worktree is selected (currentWorktreeBranch is null or matches primary),
+ // show features with no branch or primary branch
if (
!currentWorktreeBranch ||
- currentWorktreeBranch === "main" ||
- currentWorktreeBranch === "master"
+ (projectPath &&
+ isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch))
) {
return (
!f.branchName ||
- featureBranch === "main" ||
- featureBranch === "master"
+ (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch))
);
}
@@ -799,57 +709,65 @@ export function useBoardActions({
}
if (backlogFeatures.length === 0) {
+ const isOnPrimaryBranch =
+ !currentWorktreeBranch ||
+ (projectPath &&
+ isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch));
toast.info("Backlog empty", {
- description:
- currentWorktreeBranch &&
- currentWorktreeBranch !== "main" &&
- currentWorktreeBranch !== "master"
- ? `No features in backlog for branch "${currentWorktreeBranch}".`
- : "No features in backlog to start.",
+ description: !isOnPrimaryBranch
+ ? `No features in backlog for branch "${currentWorktreeBranch}".`
+ : "No features in backlog to start.",
});
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
- // This matches the auto mode service behavior for consistency
- const sortedBacklog = [...backlogFeatures].sort(
- (a, b) => (a.priority || 999) - (b.priority || 999)
- );
+ // Features with blocking dependencies are sorted to the end
+ const sortedBacklog = [...backlogFeatures].sort((a, b) => {
+ const aBlocked = enableDependencyBlocking
+ ? getBlockingDependencies(a, features).length > 0
+ : false;
+ const bBlocked = enableDependencyBlocking
+ ? getBlockingDependencies(b, features).length > 0
+ : false;
+
+ // Blocked features go to the end
+ if (aBlocked && !bBlocked) return 1;
+ if (!aBlocked && bBlocked) return -1;
+
+ // Within same blocked/unblocked group, sort by priority
+ return (a.priority || 999) - (b.priority || 999);
+ });
+
+ // Find the first feature without blocking dependencies
+ const featureToStart = sortedBacklog.find((f) => {
+ if (!enableDependencyBlocking) return true;
+ return getBlockingDependencies(f, features).length === 0;
+ });
+
+ if (!featureToStart) {
+ toast.info("No eligible features", {
+ description:
+ "All backlog features have unmet dependencies. Complete their dependencies first.",
+ });
+ return;
+ }
// Start only one feature per keypress (user must press again for next)
- const featuresToStart = sortedBacklog.slice(0, 1);
-
- for (const feature of featuresToStart) {
- // Only create worktrees if the feature is enabled
- let worktreePath: string | null = null;
- if (useWorktrees) {
- // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
- worktreePath = await getOrCreateWorktreeForFeature(feature);
- if (worktreePath) {
- await persistFeatureUpdate(feature.id, { worktreePath });
- }
- // Refresh worktree selector after creating worktree
- onWorktreeCreated?.();
- }
- // Start the implementation
- // Pass feature with worktreePath so handleRunFeature uses the correct path
- await handleStartImplementation({
- ...feature,
- worktreePath: worktreePath || undefined,
- });
- }
+ // Simplified: No worktree creation on client - server derives workDir from feature.branchName
+ await handleStartImplementation(featureToStart);
}, [
features,
runningAutoTasks,
handleStartImplementation,
- getOrCreateWorktreeForFeature,
- persistFeatureUpdate,
- onWorktreeCreated,
currentWorktreeBranch,
- useWorktrees,
+ projectPath,
+ isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch,
+ enableDependencyBlocking,
]);
- const handleDeleteAllVerified = useCallback(async () => {
+ const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
for (const feature of verifiedFeatures) {
@@ -858,22 +776,29 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
- console.error("[Board] Error stopping feature before delete:", error);
+ console.error(
+ "[Board] Error stopping feature before archive:",
+ error
+ );
}
}
- removeFeature(feature.id);
- persistFeatureDelete(feature.id);
+ // Archive the feature by setting status to completed
+ const updates = {
+ status: "completed" as const,
+ };
+ updateFeature(feature.id, updates);
+ persistFeatureUpdate(feature.id, updates);
}
- toast.success("All verified features deleted", {
- description: `Deleted ${verifiedFeatures.length} feature(s).`,
+ toast.success("All verified features archived", {
+ description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [
features,
runningAutoTasks,
autoMode,
- removeFeature,
- persistFeatureDelete,
+ updateFeature,
+ persistFeatureUpdate,
]);
return {
@@ -895,6 +820,6 @@ export function useBoardActions({
handleOutputModalNumberKeyPress,
handleForceStopFeature,
handleStartNextFeatures,
- handleDeleteAllVerified,
+ handleArchiveAllVerified,
};
}
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..6b70ed59 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;
}
@@ -101,7 +98,9 @@ export function useBoardColumnFeatures({
}
} else {
// Unknown status, default to backlog
- map.backlog.push(f);
+ if (matchesWorktree) {
+ map.backlog.push(f);
+ }
}
}
});
@@ -111,7 +110,29 @@ export function useBoardColumnFeatures({
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
- map.backlog = orderedFeatures;
+
+ // Get all features to check blocking dependencies against
+ const allFeatures = features;
+ const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
+
+ // Sort blocked features to the end of the backlog
+ // This keeps the dependency order within each group (unblocked/blocked)
+ if (enableDependencyBlocking) {
+ const unblocked: Feature[] = [];
+ const blocked: Feature[] = [];
+
+ for (const f of orderedFeatures) {
+ if (getBlockingDependencies(f, allFeatures).length > 0) {
+ blocked.push(f);
+ } else {
+ unblocked.push(f);
+ }
+ }
+
+ map.backlog = [...unblocked, ...blocked];
+ } else {
+ map.backlog = orderedFeatures;
+ }
}
return map;
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..6dd68f41 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[];
@@ -15,8 +14,6 @@ interface UseBoardDragDropProps {
updates: Partial
) => Promise;
handleStartImplementation: (feature: Feature) => Promise;
- projectPath: string | null; // Main project path
- onWorktreeCreated?: () => void; // Callback when a new worktree is created
}
export function useBoardDragDrop({
@@ -25,66 +22,12 @@ export function useBoardDragDrop({
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
- projectPath,
- onWorktreeCreated,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState(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 +61,13 @@ export function useBoardDragDrop({
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
- // - skipTests (non-TDD) items can be dragged between in_progress and verified
- // - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
- if (
- draggedFeature.status !== "backlog" &&
- draggedFeature.status !== "waiting_approval" &&
- draggedFeature.status !== "verified"
- ) {
- // Only allow dragging in_progress if it's a skipTests feature and not currently running
- if (!draggedFeature.skipTests || isRunningTask) {
+ // - in_progress items can be dragged (but not if they're currently running)
+ // - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running
+ if (draggedFeature.status === "in_progress") {
+ // Only allow dragging in_progress if it's not currently running
+ if (isRunningTask) {
console.log(
- "[Board] Cannot drag feature - TDD feature or currently running"
+ "[Board] Cannot drag feature - currently running"
);
return;
}
@@ -154,23 +93,13 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios
+ // Note: Worktrees are created server-side at execution time based on feature.branchName
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
- // Only create worktrees if the feature is enabled
- let worktreePath: string | null = null;
- if (useWorktrees) {
- // Get or create worktree based on the feature's assigned branch
- worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
- if (worktreePath) {
- await persistFeatureUpdate(featureId, { worktreePath });
- }
- // Refresh worktree selector after moving to in_progress
- onWorktreeCreated?.();
- }
// Use helper function to handle concurrency check and start implementation
- // Pass feature with worktreePath so handleRunFeature uses the correct path
- await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
+ // Server will derive workDir from feature.branchName
+ await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -195,11 +124,10 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
- // Clear justFinishedAt timestamp and worktreePath when moving back to backlog
+ // Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
- worktreePath: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -208,13 +136,23 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
- } else if (draggedFeature.skipTests) {
- // skipTests feature being moved between in_progress and verified
- if (
+ } else if (draggedFeature.status === "in_progress") {
+ // Handle in_progress features being moved
+ if (targetStatus === "backlog") {
+ // Allow moving in_progress cards back to backlog
+ moveFeature(featureId, "backlog");
+ persistFeatureUpdate(featureId, { status: "backlog" });
+ toast.info("Feature moved to backlog", {
+ description: `Moved to Backlog: ${draggedFeature.description.slice(
+ 0,
+ 50
+ )}${draggedFeature.description.length > 50 ? "..." : ""}`,
+ });
+ } else if (
targetStatus === "verified" &&
- draggedFeature.status === "in_progress"
+ draggedFeature.skipTests
) {
- // Manual verify via drag
+ // Manual verify via drag (only for skipTests features)
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
@@ -223,7 +161,10 @@ export function useBoardDragDrop({
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
- } else if (
+ }
+ } else if (draggedFeature.skipTests) {
+ // skipTests feature being moved between verified and waiting_approval
+ if (
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
@@ -237,10 +178,9 @@ export function useBoardDragDrop({
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
- // Allow moving skipTests cards back to backlog
+ // Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, "backlog");
- // Clear worktreePath when moving back to backlog
- persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
+ persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -263,8 +203,7 @@ export function useBoardDragDrop({
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
- // Clear worktreePath when moving back to backlog
- persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined });
+ persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -280,9 +219,6 @@ export function useBoardDragDrop({
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
- getOrCreateWorktreeForFeature,
- onWorktreeCreated,
- useWorktrees,
]
);
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 bc392e85..1ce9fbc4 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";
@@ -55,7 +55,7 @@ interface KanbanBoardProps {
onStartNextFeatures: () => void;
onShowSuggestions: () => void;
suggestionsCount: number;
- onDeleteAllVerified: () => void;
+ onArchiveAllVerified: () => void;
}
export function KanbanBoard({
@@ -87,7 +87,7 @@ export function KanbanBoard({
onStartNextFeatures,
onShowSuggestions,
suggestionsCount,
- onDeleteAllVerified,
+ onArchiveAllVerified,
}: KanbanBoardProps) {
return (
-
- 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..a395edf5
--- /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";
+
+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`}
+ aria-labelledby={`${testIdPrefix}-label`}
+ >
+
+
+
+
+
+
+
+
+
+ {!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 d24cb2ef..a0c23bc3 100644
--- a/apps/app/src/components/views/board-view/shared/index.ts
+++ b/apps/app/src/components/views/board-view/shared/index.ts
@@ -4,4 +4,5 @@ export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
+export * from "./branch-selector";
export * from "./planning-mode-selector";
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-worktree-actions.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts
index 02224ad9..b0e573ff 100644
--- a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts
+++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
- fetchWorktrees: () => Promise;
+ fetchWorktrees: () => Promise | undefined>;
fetchBranches: (worktreePath: string) => Promise;
}
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..bfb1fbad 100644
--- a/apps/app/src/components/views/context-view.tsx
+++ b/apps/app/src/components/views/context-view.tsx
@@ -212,16 +212,20 @@ export function ContextView() {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
-
+
+ // Only reload files on success
+ await loadContextFiles();
+ } catch (error) {
+ console.error("Failed to add file:", error);
+ // Optionally show error toast to user here
+ } finally {
+ // Close dialog and reset state
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
- await loadContextFiles();
- } catch (error) {
- console.error("Failed to add file:", error);
}
};
diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts
index cb5112c3..9b4f346e 100644
--- a/apps/app/src/hooks/use-auto-mode.ts
+++ b/apps/app/src/hooks/use-auto-mode.ts
@@ -18,7 +18,6 @@ export function useAutoMode() {
setAutoModeRunning,
addRunningTask,
removeRunningTask,
- clearRunningTasks,
currentProject,
addAutoModeActivity,
maxConcurrency,
@@ -30,7 +29,6 @@ export function useAutoMode() {
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
- clearRunningTasks: state.clearRunningTasks,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
@@ -126,33 +124,6 @@ export function useAutoMode() {
}
break;
- case "auto_mode_stopped":
- // Auto mode was explicitly stopped (by user or error)
- setAutoModeRunning(eventProjectId, false);
- clearRunningTasks(eventProjectId);
- console.log("[AutoMode] Auto mode stopped");
- break;
-
- case "auto_mode_started":
- // Auto mode started - ensure UI reflects running state
- console.log("[AutoMode] Auto mode started:", event.message);
- break;
-
- case "auto_mode_idle":
- // Auto mode is running but has no pending features to pick up
- // This is NOT a stop - auto mode keeps running and will pick up new features
- console.log("[AutoMode] Auto mode idle - waiting for new features");
- break;
-
- case "auto_mode_complete":
- // Legacy event - only handle if it looks like a stop (for backwards compatibility)
- if (event.message === "Auto mode stopped") {
- setAutoModeRunning(eventProjectId, false);
- clearRunningTasks(eventProjectId);
- console.log("[AutoMode] Auto mode stopped (legacy event)");
- }
- break;
-
case "auto_mode_error":
if (event.featureId && event.error) {
// Check if this is a user-initiated cancellation or abort (not a real error)
@@ -356,130 +327,36 @@ export function useAutoMode() {
projectId,
addRunningTask,
removeRunningTask,
- clearRunningTasks,
- setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
currentProject?.path,
]);
- // Restore auto mode for all projects that were running when app was closed
- // This runs once on mount to restart auto loops for persisted running states
- useEffect(() => {
- const api = getElectronAPI();
- if (!api?.autoMode) return;
-
- // Find all projects that have auto mode marked as running
- const projectsToRestart: Array<{ projectId: string; projectPath: string }> =
- [];
- for (const [projectId, state] of Object.entries(autoModeByProject)) {
- if (state.isRunning) {
- // Find the project path for this project ID
- const project = projects.find((p) => p.id === projectId);
- if (project) {
- projectsToRestart.push({ projectId, projectPath: project.path });
- }
- }
- }
-
- // Restart auto mode for each project
- for (const { projectId, projectPath } of projectsToRestart) {
- console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`);
- api.autoMode
- .start(projectPath, maxConcurrency)
- .then((result) => {
- if (!result.success) {
- console.error(
- `[AutoMode] Failed to restore auto mode for ${projectPath}:`,
- result.error
- );
- // Mark as not running if we couldn't restart
- setAutoModeRunning(projectId, false);
- } else {
- console.log(`[AutoMode] Restored auto mode for ${projectPath}`);
- }
- })
- .catch((error) => {
- console.error(
- `[AutoMode] Error restoring auto mode for ${projectPath}:`,
- error
- );
- setAutoModeRunning(projectId, false);
- });
- }
- // Only run once on mount - intentionally empty dependency array
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Start auto mode
- const start = useCallback(async () => {
+ // Start auto mode - UI only, feature pickup is handled in board-view.tsx
+ const start = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- throw new Error("Auto mode API not available");
- }
-
- const result = await api.autoMode.start(
- currentProject.path,
- maxConcurrency
- );
-
- if (result.success) {
- setAutoModeRunning(currentProject.id, true);
- console.log(
- `[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`
- );
- } else {
- console.error("[AutoMode] Failed to start:", result.error);
- throw new Error(result.error || "Failed to start auto mode");
- }
- } catch (error) {
- console.error("[AutoMode] Error starting:", error);
- if (currentProject) {
- setAutoModeRunning(currentProject.id, false);
- }
- throw error;
- }
+ setAutoModeRunning(currentProject.id, true);
+ console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
}, [currentProject, setAutoModeRunning, maxConcurrency]);
- // Stop auto mode - only turns off the toggle, running tasks continue
- const stop = useCallback(async () => {
+ // Stop auto mode - UI only, running tasks continue until natural completion
+ const stop = useCallback(() => {
if (!currentProject) {
console.error("No project selected");
return;
}
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- throw new Error("Auto mode API not available");
- }
-
- const result = await api.autoMode.stop(currentProject.path);
-
- if (result.success) {
- setAutoModeRunning(currentProject.id, false);
- // NOTE: We intentionally do NOT clear running tasks here.
- // Stopping auto mode only turns off the toggle to prevent new features
- // from being picked up. Running tasks will complete naturally and be
- // removed via the auto_mode_feature_complete event.
- console.log(
- "[AutoMode] Stopped successfully - running tasks will continue"
- );
- } else {
- console.error("[AutoMode] Failed to stop:", result.error);
- throw new Error(result.error || "Failed to stop auto mode");
- }
- } catch (error) {
- console.error("[AutoMode] Error stopping:", error);
- throw error;
- }
+ setAutoModeRunning(currentProject.id, false);
+ // NOTE: We intentionally do NOT clear running tasks here.
+ // Stopping auto mode only turns off the toggle to prevent new features
+ // from being picked up. Running tasks will complete naturally and be
+ // removed via the auto_mode_feature_complete event.
+ console.log("[AutoMode] Stopped - running tasks will continue");
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature
diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts
index c21a0bd9..2a50016a 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[];
@@ -1449,7 +1447,6 @@ function createMockAutoModeAPI(): AutoModeAPI {
return {
success: true,
isRunning: mockAutoModeRunning,
- autoLoopRunning: mockAutoModeRunning,
currentFeatureId: mockAutoModeRunning ? "feature-0" : null,
runningFeatures: Array.from(mockRunningFeatures),
runningCount: mockRunningFeatures.size,
@@ -2617,7 +2614,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
success: true,
runningAgents,
totalCount: runningAgents.length,
- autoLoopRunning: mockAutoModeRunning,
};
},
};
diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts
index b79ba70d..b4a40508 100644
--- a/apps/app/src/lib/http-api-client.ts
+++ b/apps/app/src/lib/http-api-client.ts
@@ -752,7 +752,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 6eeae25a..bec00c75 100644
--- a/apps/app/src/store/app-store.ts
+++ b/apps/app/src/store/app-store.ts
@@ -299,9 +299,8 @@ export interface Feature {
error?: string; // Error message if the agent errored during processing
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
dependencies?: string[]; // Array of feature IDs this feature depends on
- // Worktree info - set when a feature is being worked on in an isolated git worktree
- worktreePath?: string; // Path to the worktree directory
- branchName?: string; // Name of the feature branch
+ // Branch info - worktree path is derived at runtime from branchName
+ branchName?: string; // Name of the feature branch (undefined = use current worktree)
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
@@ -433,7 +432,10 @@ export interface AppState {
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
- currentWorktreeByProject: Record;
+ currentWorktreeByProject: Record<
+ string,
+ { path: string | null; branch: string }
+ >;
worktreesByProject: Record<
string,
Array<{
@@ -629,7 +631,11 @@ export interface AppActions {
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
- setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
+ setCurrentWorktree: (
+ projectPath: string,
+ worktreePath: string | null,
+ branch: string
+ ) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
@@ -640,7 +646,9 @@ export interface AppActions {
changedFilesCount?: number;
}>
) => void;
- getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
+ getCurrentWorktree: (
+ projectPath: string
+ ) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
@@ -648,6 +656,8 @@ export interface AppActions {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
+ isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
+ getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
@@ -1402,7 +1412,8 @@ export const useAppStore = create()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
- setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
+ setEnableDependencyBlocking: (enabled) =>
+ set({ enableDependencyBlocking: enabled }),
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1435,6 +1446,18 @@ export const useAppStore = create()(
return get().worktreesByProject[projectPath] ?? [];
},
+ isPrimaryWorktreeBranch: (projectPath, branchName) => {
+ const worktrees = get().worktreesByProject[projectPath] ?? [];
+ const primary = worktrees.find((w) => w.isMain);
+ return primary?.branch === branchName;
+ },
+
+ getPrimaryWorktreeBranch: (projectPath) => {
+ const worktrees = get().worktreesByProject[projectPath] ?? [];
+ const primary = worktrees.find((w) => w.isMain);
+ return primary?.branch ?? null;
+ },
+
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
@@ -2298,7 +2321,8 @@ export const useAppStore = create()(
// 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 a0e3f31e..244b4c23 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;
@@ -375,20 +351,6 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
- start: (
- projectPath: string,
- maxConcurrency?: number
- ) => Promise<{
- success: boolean;
- error?: string;
- }>;
-
- stop: (projectPath: string) => Promise<{
- success: boolean;
- error?: string;
- runningFeatures?: number;
- }>;
-
stopFeature: (featureId: string) => Promise<{
success: boolean;
error?: string;
@@ -396,7 +358,6 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
- autoLoopRunning?: boolean;
isRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
@@ -408,8 +369,7 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
- useWorktrees?: boolean,
- worktreePath?: string
+ useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -455,7 +415,7 @@ export interface AutoModeAPI {
featureId: string,
prompt: string,
imagePaths?: string[],
- worktreePath?: string
+ useWorktrees?: boolean
) => Promise<{
success: boolean;
passes?: boolean;
@@ -708,6 +668,10 @@ export interface WorktreeAPI {
hasChanges?: boolean;
changedFilesCount?: number;
}>;
+ removedWorktrees?: Array<{
+ path: string;
+ branch: string;
+ }>;
error?: string;
}>;
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..8782ac62 100644
--- a/apps/app/tests/utils/views/board.ts
+++ b/apps/app/tests/utils/views/board.ts
@@ -120,7 +120,9 @@ export async function getDragHandleForFeature(
*/
export async function clickAddFeature(page: Page): Promise {
await page.click('[data-testid="add-feature-button"]');
- await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 });
+ await page.waitForSelector('[data-testid="add-feature-dialog"]', {
+ timeout: 5000,
+ });
}
/**
@@ -132,17 +134,30 @@ export async function fillAddFeatureDialog(
options?: { branch?: string; category?: string }
): Promise {
// Fill description (using the dropzone textarea)
- const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
+ const descriptionInput = page
+ .locator('[data-testid="add-feature-dialog"] textarea')
+ .first();
await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete)
if (options?.branch) {
- const branchButton = page.locator('[data-testid="feature-branch-input"]');
- await branchButton.click();
+ // First, select "Other branch" radio option if not already selected
+ const otherBranchRadio = page
+ .locator('[data-testid="feature-radio-group"]')
+ .locator('[id="feature-other"]');
+ await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
+ await otherBranchRadio.click();
+ // Wait for the branch input to appear
+ await page.waitForTimeout(300);
+
+ // Now click on the branch input (autocomplete)
+ const branchInput = page.locator('[data-testid="feature-input"]');
+ await branchInput.waitFor({ state: "visible", timeout: 5000 });
+ await branchInput.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
- const commandInput = page.locator('[cmdk-input]');
+ const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
@@ -152,10 +167,12 @@ export async function fillAddFeatureDialog(
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
- const categoryButton = page.locator('[data-testid="feature-category-input"]');
+ const categoryButton = page.locator(
+ '[data-testid="feature-category-input"]'
+ );
await categoryButton.click();
await page.waitForTimeout(300);
- const commandInput = page.locator('[cmdk-input]');
+ const commandInput = page.locator("[cmdk-input]");
await commandInput.fill(options.category);
await commandInput.press("Enter");
await page.waitForTimeout(200);
@@ -201,8 +218,13 @@ export async function getWorktreeSelector(page: Page): Promise {
/**
* Click on a branch button in the worktree selector
*/
-export async function selectWorktreeBranch(page: Page, branchName: string): Promise {
- const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
+export async function selectWorktreeBranch(
+ page: Page,
+ branchName: string
+): Promise {
+ const branchButton = page.getByRole("button", {
+ name: new RegExp(branchName, "i"),
+ });
await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update
}
@@ -210,9 +232,13 @@ export async function selectWorktreeBranch(page: Page, branchName: string): Prom
/**
* Get the currently selected branch in the worktree selector
*/
-export async function getSelectedWorktreeBranch(page: Page): Promise {
+export async function getSelectedWorktreeBranch(
+ page: Page
+): Promise {
// The main branch button has aria-pressed="true" when selected
- const selectedButton = page.locator('[data-testid="worktree-selector"] button[aria-pressed="true"]');
+ const selectedButton = page.locator(
+ '[data-testid="worktree-selector"] button[aria-pressed="true"]'
+ );
const text = await selectedButton.textContent().catch(() => null);
return text?.trim() || null;
}
@@ -220,7 +246,12 @@ export async function getSelectedWorktreeBranch(page: Page): Promise {
- const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") });
+export async function isWorktreeBranchVisible(
+ page: Page,
+ branchName: string
+): Promise {
+ const branchButton = page.getByRole("button", {
+ name: new RegExp(branchName, "i"),
+ });
return await branchButton.isVisible().catch(() => false);
}
diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts
index f1eccbb9..7f143868 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");
@@ -2337,7 +2353,7 @@ test.describe("Worktree Integration Tests", () => {
// Edit Feature with Branch Change
// ==========================================================================
- test("should create worktree when editing a feature and selecting a new branch", async ({
+ test("should update branchName when editing a feature and selecting a new branch", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
@@ -2383,7 +2399,7 @@ test.describe("Worktree Integration Tests", () => {
const newBranchName = "feature/edited-branch";
const expectedWorktreePath = getWorktreePath(testRepo.path, newBranchName);
- // Verify worktree does NOT exist before editing
+ // Verify worktree does NOT exist before editing (worktrees are created at execution time)
expect(fs.existsSync(expectedWorktreePath)).toBe(false);
// Find and click the edit button on the feature card
@@ -2398,8 +2414,12 @@ test.describe("Worktree Integration Tests", () => {
const editDialog = page.locator('[data-testid="edit-feature-dialog"]');
await expect(editDialog).toBeVisible({ timeout: 5000 });
+ // Select "Other branch" to enable the branch input
+ const otherBranchRadio = page.locator('label[for="edit-feature-other"]');
+ await otherBranchRadio.click();
+
// Find and click on the branch input to open the autocomplete
- const branchInput = page.locator('[data-testid="edit-feature-branch"]');
+ const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2415,20 +2435,22 @@ test.describe("Worktree Integration Tests", () => {
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
await saveButton.click();
- // Wait for the dialog to close and worktree to be created
+ // Wait for the dialog to close
await page.waitForTimeout(2000);
- // Verify worktree was automatically created
- expect(fs.existsSync(expectedWorktreePath)).toBe(true);
+ // Verify worktree was NOT created during editing (worktrees are created at execution time)
+ expect(fs.existsSync(expectedWorktreePath)).toBe(false);
- // Verify the branch was created
+ // Verify branch was NOT created (created at execution time)
const branches = await listBranches(testRepo.path);
- expect(branches).toContain(newBranchName);
+ expect(branches).not.toContain(newBranchName);
- // Verify feature was updated with correct branch and worktreePath
+ // Verify feature was updated with correct branchName only
+ // Note: worktreePath is no longer stored - worktrees are created server-side at execution time
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBe(newBranchName);
- expect(featureData.worktreePath).toBe(expectedWorktreePath);
+ // worktreePath should not exist in the feature data
+ expect(featureData.worktreePath).toBeUndefined();
});
test("should not create worktree when editing a feature and selecting main branch", async ({
@@ -2491,7 +2513,7 @@ test.describe("Worktree Integration Tests", () => {
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Find and click on the branch input
- const branchInput = page.locator('[data-testid="edit-feature-branch"]');
+ const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2550,7 +2572,7 @@ test.describe("Worktree Integration Tests", () => {
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Change to the existing branch
- const branchInput = page.locator('[data-testid="edit-feature-branch"]');
+ const branchInput = page.locator('[data-testid="edit-feature-input"]');
await branchInput.click();
await page.waitForTimeout(300);
@@ -2571,7 +2593,8 @@ test.describe("Worktree Integration Tests", () => {
);
expect(matchingWorktrees.length).toBe(1);
- // Verify feature was updated with the correct worktreePath
+ // Verify feature was updated with the correct branchName
+ // Note: worktreePath is no longer stored - worktrees are created server-side at execution time
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
@@ -2586,6 +2609,7 @@ test.describe("Worktree Integration Tests", () => {
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.branchName).toBe(existingBranch);
- expect(featureData.worktreePath).toBe(existingWorktreePath);
+ // worktreePath should not exist in the feature data (worktrees are created at execution time)
+ expect(featureData.worktreePath).toBeUndefined();
});
});
diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts
index 9964289c..b37907c8 100644
--- a/apps/server/src/routes/app-spec/index.ts
+++ b/apps/server/src/routes/app-spec/index.ts
@@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
+
diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts
index 79ed58f8..8ad4510c 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";
@@ -22,8 +20,6 @@ import { createApprovePlanHandler } from "./routes/approve-plan.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
- router.post("/start", createStartHandler(autoModeService));
- router.post("/stop", createStopHandler(autoModeService));
router.post("/stop-feature", createStopFeatureHandler(autoModeService));
router.post("/status", createStatusHandler(autoModeService));
router.post("/run-feature", createRunFeatureHandler(autoModeService));
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..134c36df 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;
}
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/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts
index 575d0758..b6168282 100644
--- a/apps/server/src/routes/setup/routes/delete-api-key.ts
+++ b/apps/server/src/routes/setup/routes/delete-api-key.ts
@@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() {
}
+
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 721d69da..61130314 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -19,15 +19,11 @@ import type { EventEmitter } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
-import { classifyError } from "../lib/error-handler.js";
+import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
-import {
- getFeatureDir,
- getFeaturesDir,
- getAutomakerDir,
- getWorktreesDir,
-} from "../lib/automaker-paths.js";
+import { FeatureLoader } from "./feature-loader.js";
+import { getFeatureDir, getAutomakerDir, getFeaturesDir } from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
@@ -313,10 +309,11 @@ interface RunningFeature {
startTime: number;
}
-interface AutoModeConfig {
- maxConcurrency: number;
- useWorktrees: boolean;
+interface AutoLoopState {
projectPath: string;
+ maxConcurrency: number;
+ abortController: AbortController;
+ isRunning: boolean;
}
interface PendingApproval {
@@ -326,9 +323,17 @@ interface PendingApproval {
projectPath: string;
}
+interface AutoModeConfig {
+ maxConcurrency: number;
+ useWorktrees: boolean;
+ projectPath: string;
+}
+
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map();
+ private autoLoop: AutoLoopState | null = null;
+ private featureLoader = new FeatureLoader();
private autoLoopRunning = false;
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
@@ -452,7 +457,6 @@ export class AutoModeService {
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
- * @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
@@ -465,74 +469,88 @@ export class AutoModeService {
}
): Promise {
if (this.runningFeatures.has(featureId)) {
- throw new Error(`Feature ${featureId} is already running`);
+ throw new Error("already running");
}
+ // Add to running features immediately to prevent race conditions
const abortController = new AbortController();
- const branchName = `feature/${featureId}`;
- let worktreePath: string | null = null;
-
- // Use provided worktree path if given, otherwise setup new worktree if enabled
- if (providedWorktreePath) {
- // Resolve to absolute path - critical for cross-platform compatibility
- // On Windows, relative paths or paths with forward slashes may not work correctly with cwd
- // On all platforms, absolute paths ensure commands execute in the correct directory
- try {
- // Resolve relative paths relative to projectPath, absolute paths as-is
- const resolvedPath = path.isAbsolute(providedWorktreePath)
- ? path.resolve(providedWorktreePath)
- : path.resolve(projectPath, providedWorktreePath);
-
- // Verify the path exists before using it
- await fs.access(resolvedPath);
- worktreePath = resolvedPath;
- console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
- } catch (error) {
- console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
- // Fall through to create new worktree or use project path
- }
- }
-
- if (!worktreePath && useWorktrees) {
- // No specific worktree provided, create a new one for this feature
- worktreePath = await this.setupWorktree(
- projectPath,
- featureId,
- branchName
- );
- }
-
- // Ensure workDir is always an absolute path for cross-platform compatibility
- const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
-
- this.runningFeatures.set(featureId, {
+ const tempRunningFeature: RunningFeature = {
featureId,
projectPath,
- worktreePath,
- branchName,
+ worktreePath: null,
+ branchName: null,
abortController,
isAutoMode,
startTime: Date.now(),
- });
-
- // Emit feature start event
- this.emitAutoModeEvent("auto_mode_feature_start", {
- featureId,
- projectPath,
- feature: {
- id: featureId,
- title: "Loading...",
- description: "Feature is starting",
- },
- });
+ };
+ this.runningFeatures.set(featureId, tempRunningFeature);
try {
- // Load feature details
+ // Check if feature has existing context - if so, resume instead of starting fresh
+ const hasExistingContext = await this.contextExists(
+ projectPath,
+ featureId
+ );
+ if (hasExistingContext) {
+ console.log(
+ `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh`
+ );
+ // Remove from running features temporarily, resumeFeature will add it back
+ this.runningFeatures.delete(featureId);
+ return this.resumeFeature(projectPath, featureId, useWorktrees);
+ }
+
+ // Emit feature start event early
+ this.emitAutoModeEvent("auto_mode_feature_start", {
+ featureId,
+ projectPath,
+ feature: {
+ id: featureId,
+ title: "Loading...",
+ description: "Feature is starting",
+ },
+ });
+ // Load feature details FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
+ // Derive workDir from feature.branchName
+ // If no branchName, derive from feature ID: feature/{featureId}
+ let worktreePath: string | null = null;
+ const branchName = feature.branchName || `feature/${featureId}`;
+
+ if (useWorktrees && branchName) {
+ // Try to find existing worktree for this branch
+ worktreePath = await this.findExistingWorktreeForBranch(
+ projectPath,
+ branchName
+ );
+
+ if (!worktreePath) {
+ // Create worktree for this branch
+ worktreePath = await this.setupWorktree(
+ projectPath,
+ featureId,
+ branchName
+ );
+ }
+
+ console.log(
+ `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`
+ );
+ }
+
+ // Ensure workDir is always an absolute path for cross-platform compatibility
+ const workDir = worktreePath
+ ? path.resolve(worktreePath)
+ : path.resolve(projectPath);
+
+ // Update running feature with actual worktree info
+ tempRunningFeature.worktreePath = worktreePath;
+ tempRunningFeature.branchName = branchName;
+
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
@@ -567,7 +585,7 @@ export class AutoModeService {
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(
- `[AutoMode] Executing feature ${featureId} with model: ${model}`
+ `[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`
);
// Run the agent with the feature's model and images
@@ -576,6 +594,7 @@ export class AutoModeService {
featureId,
prompt,
abortController,
+ projectPath,
imagePaths,
model,
{
@@ -596,7 +615,7 @@ export class AutoModeService {
featureId,
passes: true,
message: `Feature completed in ${Math.round(
- (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
+ (Date.now() - tempRunningFeature.startTime) / 1000
)}s`,
projectPath,
});
@@ -651,6 +670,10 @@ export class AutoModeService {
featureId: string,
useWorktrees = false
): Promise {
+ if (this.runningFeatures.has(featureId)) {
+ throw new Error("already running");
+ }
+
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -674,7 +697,9 @@ export class AutoModeService {
);
}
- // No context, start fresh
+ // No context, start fresh - executeFeature will handle adding to runningFeatures
+ // Remove the temporary entry we added
+ this.runningFeatures.delete(featureId);
return this.executeFeature(projectPath, featureId, useWorktrees, false);
}
@@ -686,7 +711,7 @@ export class AutoModeService {
featureId: string,
prompt: string,
imagePaths?: string[],
- providedWorktreePath?: string
+ useWorktrees = true
): Promise {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -694,32 +719,30 @@ export class AutoModeService {
const abortController = new AbortController();
- // Use the provided worktreePath (from the feature's assigned branch)
- // Fall back to project path if not provided
+ // Load feature info for context FIRST to get branchName
+ const feature = await this.loadFeature(projectPath, featureId);
+
+ // Derive workDir from feature.branchName
+ // If no branchName, derive from feature ID: feature/{featureId}
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
+ const branchName = feature?.branchName || `feature/${featureId}`;
- if (providedWorktreePath) {
- try {
- // Resolve to absolute path - critical for cross-platform compatibility
- // On Windows, relative paths or paths with forward slashes may not work correctly with cwd
- // On all platforms, absolute paths ensure commands execute in the correct directory
- const resolvedPath = path.isAbsolute(providedWorktreePath)
- ? path.resolve(providedWorktreePath)
- : path.resolve(projectPath, providedWorktreePath);
-
- await fs.access(resolvedPath);
- workDir = resolvedPath;
- worktreePath = resolvedPath;
- } catch {
- // Worktree path provided but doesn't exist, use project path
- console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
+ if (useWorktrees && branchName) {
+ // Try to find existing worktree for this branch
+ worktreePath = await this.findExistingWorktreeForBranch(
+ projectPath,
+ branchName
+ );
+
+ if (worktreePath) {
+ workDir = worktreePath;
+ console.log(
+ `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}`
+ );
}
}
- // Load feature info for context
- const feature = await this.loadFeature(projectPath, featureId);
-
// Load previous agent output if it exists
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
@@ -756,7 +779,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
projectPath,
worktreePath,
- branchName: worktreePath ? path.basename(worktreePath) : null,
+ branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -853,6 +876,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
fullPrompt,
abortController,
+ projectPath,
allImagePaths.length > 0 ? allImagePaths : imagePaths,
model,
{
@@ -975,17 +999,25 @@ Address the follow-up instructions above. Review the previous work and make the
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
- console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
+ console.log(
+ `[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`
+ );
}
} else {
// Fallback: try to find worktree at legacy location
- const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
+ const legacyWorktreePath = path.join(
+ projectPath,
+ ".worktrees",
+ featureId
+ );
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
- console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
+ console.log(
+ `[AutoMode] No worktree found, committing in project path: ${workDir}`
+ );
}
}
@@ -1135,18 +1167,17 @@ Format your response as a structured markdown document.`;
}
}
+
/**
* Get current status
*/
getStatus(): {
isRunning: boolean;
- autoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
} {
return {
- isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
- autoLoopRunning: this.autoLoopRunning,
+ isRunning: this.runningFeatures.size > 0,
runningFeatures: Array.from(this.runningFeatures.keys()),
runningCount: this.runningFeatures.size,
};
@@ -1323,6 +1354,7 @@ Format your response as a structured markdown document.`;
// Private helpers
+
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
@@ -1381,10 +1413,15 @@ Format your response as a structured markdown document.`;
branchName: string
): Promise {
// First, check if git already has a worktree for this branch (anywhere)
- const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
+ const existingWorktree = await this.findExistingWorktreeForBranch(
+ projectPath,
+ branchName
+ );
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
- console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
+ console.log(
+ `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`
+ );
return existingWorktree;
}
@@ -1683,6 +1720,7 @@ This helps parse your summary correctly in the output logs.`;
featureId: string,
prompt: string,
abortController: AbortController,
+ projectPath: string,
imagePaths?: string[],
model?: string,
options?: {
@@ -1692,7 +1730,7 @@ This helps parse your summary correctly in the output logs.`;
previousContent?: string;
}
): Promise {
- const projectPath = options?.projectPath || workDir;
+ const finalProjectPath = options?.projectPath || projectPath;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
@@ -1708,7 +1746,9 @@ This helps parse your summary correctly in the output logs.`;
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") {
- console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
+ console.log(
+ `[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`
+ );
// Simulate some work being done
await this.sleep(500);
@@ -1740,8 +1780,7 @@ This helps parse your summary correctly in the output logs.`;
await this.sleep(200);
// Save mock agent output
- const configProjectPath = this.config?.projectPath || workDir;
- const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
+ const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
@@ -1759,7 +1798,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
- console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`);
+ console.log(
+ `[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`
+ );
return;
}
@@ -1812,10 +1853,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
let specDetected = false;
// Agent output goes to .automaker directory
- // Note: We use the original projectPath here (from config), not workDir
- // because workDir might be a worktree path
- const configProjectPath = this.config?.projectPath || workDir;
- const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
+ // Note: We use projectPath here, not workDir, because workDir might be a worktree path
+ const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
@@ -1829,7 +1868,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
- console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
+ console.error(
+ `[AutoMode] Failed to write agent output for ${featureId}:`,
+ error
+ );
}
};
@@ -1848,11 +1890,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
- if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
- if (responseText.endsWith('\n')) {
- responseText += '\n';
+ if (responseText.length > 0 && !responseText.endsWith("\n\n")) {
+ if (responseText.endsWith("\n")) {
+ responseText += "\n";
} else {
- responseText += '\n\n';
+ responseText += "\n\n";
}
}
responseText += block.text || "";
@@ -2275,12 +2317,16 @@ Implement all the changes described in the plan above.`;
});
// Also add to file output for persistence
- if (responseText.length > 0 && !responseText.endsWith('\n')) {
- responseText += '\n';
+ if (responseText.length > 0 && !responseText.endsWith("\n")) {
+ responseText += "\n";
}
responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) {
- responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
+ responseText += `Input: ${JSON.stringify(
+ block.input,
+ null,
+ 2
+ )}\n`;
}
scheduleWrite();
}
@@ -2420,7 +2466,28 @@ Begin implementing task ${task.id} now.`;
});
}
- private sleep(ms: number): Promise {
- 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 14f567b5..42fabbb2 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)
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts
index 4e0409f5..09483e78 100644
--- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts
+++ b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts
@@ -324,9 +324,9 @@ describe("auto-mode-service.ts - Planning Mode", () => {
describe("status management", () => {
it("should report correct status", () => {
const status = service.getStatus();
- expect(status.autoLoopRunning).toBe(false);
expect(status.runningFeatures).toEqual([]);
expect(status.isRunning).toBe(false);
+ expect(status.runningCount).toBe(0);
});
});
});
diff --git a/docs/server/route-organization.md b/docs/server/route-organization.md
index bb8df194..410bd5b9 100644
--- a/docs/server/route-organization.md
+++ b/docs/server/route-organization.md
@@ -582,3 +582,4 @@ The route organization pattern provides:
Apply this pattern to all route modules for consistency and improved code quality.
+
diff --git a/package-lock.json b/package-lock.json
index 00e5a36e..8f8487fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,9 +33,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
@@ -1765,57 +1767,6 @@
}
}
},
- "apps/app/node_modules/@radix-ui/react-presence": {
- "version": "1.1.5",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-use-layout-effect": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "apps/app/node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.11",
- "license": "MIT",
- "dependencies": {
- "@radix-ui/primitive": "1.1.3",
- "@radix-ui/react-collection": "1.1.7",
- "@radix-ui/react-compose-refs": "1.1.2",
- "@radix-ui/react-context": "1.1.2",
- "@radix-ui/react-direction": "1.1.1",
- "@radix-ui/react-id": "1.1.1",
- "@radix-ui/react-primitive": "2.1.3",
- "@radix-ui/react-use-callback-ref": "1.1.1",
- "@radix-ui/react-use-controllable-state": "1.2.2"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"apps/app/node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"license": "MIT",
@@ -10909,6 +10860,30 @@
}
}
},
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
@@ -10932,6 +10907,69 @@
}
}
},
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
+ "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -10993,6 +11031,35 @@
}
}
},
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",