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 ( + + + + Archive All Verified Features + + Are you sure you want to archive all verified features? They will be + moved to the archive box. + {verifiedCount > 0 && ( + + {verifiedCount} feature(s) will be archived. + + )} + + + + + + + + + ); +} + + 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",