From 0549b8085aa136ca66bc081b8dc9fe76fcf13bbe Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Wed, 17 Dec 2025 22:29:39 -0500 Subject: [PATCH] feat: enhance UI components and branch management - Added new RadioGroup and Switch components for better UI interaction. - Introduced BranchSelector for improved branch selection in feature dialogs. - Updated Autocomplete and BranchAutocomplete components to handle error states. - Refactored feature management to archive verified features instead of deleting them. - Enhanced worktree handling by removing worktreePath from features, relying on branchName instead. - Improved auto mode functionality by integrating branch management and worktree updates. - Cleaned up unused code and optimized existing logic for better performance. --- apps/app/package.json | 2 + apps/app/src/components/ui/autocomplete.tsx | 3 + .../src/components/ui/branch-autocomplete.tsx | 3 + apps/app/src/components/ui/radio-group.tsx | 45 ++ apps/app/src/components/ui/switch.tsx | 30 + apps/app/src/components/views/board-view.tsx | 294 +++++++- .../views/board-view/board-header.tsx | 47 +- .../board-view/components/kanban-card.tsx | 2 +- .../board-view/dialogs/add-feature-dialog.tsx | 60 +- .../dialogs/archive-all-verified-dialog.tsx | 55 ++ .../board-view/dialogs/create-pr-dialog.tsx | 33 +- .../dialogs/edit-feature-dialog.tsx | 80 ++- .../views/board-view/dialogs/index.ts | 2 +- .../board-view/hooks/use-board-actions.ts | 302 ++++----- .../hooks/use-board-column-features.ts | 55 +- .../board-view/hooks/use-board-drag-drop.ts | 130 +--- .../board-view/hooks/use-board-effects.ts | 16 +- .../views/board-view/kanban-board.tsx | 16 +- .../board-view/shared/branch-selector.tsx | 98 +++ .../views/board-view/shared/index.ts | 1 + .../components/worktree-actions-dropdown.tsx | 3 +- .../hooks/use-running-features.ts | 18 +- .../worktree-panel/hooks/use-worktrees.ts | 16 +- .../views/board-view/worktree-panel/types.ts | 2 +- .../worktree-panel/worktree-panel.tsx | 12 +- .../app/src/components/views/context-view.tsx | 10 + apps/app/src/hooks/use-auto-mode.ts | 146 +--- apps/app/src/lib/electron.ts | 4 - apps/app/src/lib/http-api-client.ts | 1 - apps/app/src/store/app-store.ts | 40 +- apps/app/src/types/electron.d.ts | 48 +- apps/app/tests/utils/core/constants.ts | 2 +- apps/app/tests/utils/views/board.ts | 13 +- apps/app/tests/worktree-integration.spec.ts | 68 +- apps/server/src/routes/auto-mode/index.ts | 4 - .../auto-mode/routes/follow-up-feature.ts | 30 +- .../routes/auto-mode/routes/resume-feature.ts | 13 +- .../routes/auto-mode/routes/run-feature.ts | 22 +- .../src/routes/auto-mode/routes/start.ts | 31 - .../src/routes/auto-mode/routes/stop.ts | 19 - .../src/routes/running-agents/routes/index.ts | 1 - .../server/src/routes/worktree/routes/list.ts | 46 +- apps/server/src/services/auto-mode-service.ts | 561 ++++++++-------- apps/server/src/services/feature-loader.ts | 2 + package-lock.json | 629 ++++++++++-------- 45 files changed, 1669 insertions(+), 1346 deletions(-) create mode 100644 apps/app/src/components/ui/radio-group.tsx create mode 100644 apps/app/src/components/ui/switch.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/shared/branch-selector.tsx delete mode 100644 apps/server/src/routes/auto-mode/routes/start.ts delete mode 100644 apps/server/src/routes/auto-mode/routes/stop.ts diff --git a/apps/app/package.json b/apps/app/package.json index ad9100db..d518c90e 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -45,8 +45,10 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", diff --git a/apps/app/src/components/ui/autocomplete.tsx b/apps/app/src/components/ui/autocomplete.tsx index 23e094c6..7417a97e 100644 --- a/apps/app/src/components/ui/autocomplete.tsx +++ b/apps/app/src/components/ui/autocomplete.tsx @@ -35,6 +35,7 @@ interface AutocompleteProps { emptyMessage?: string; className?: string; disabled?: boolean; + error?: boolean; icon?: LucideIcon; allowCreate?: boolean; createLabel?: (value: string) => string; @@ -58,6 +59,7 @@ export function Autocomplete({ emptyMessage = "No results found.", className, disabled = false, + error = false, icon: Icon, allowCreate = false, createLabel = (v) => `Create "${v}"`, @@ -130,6 +132,7 @@ export function Autocomplete({ className={cn( "w-full justify-between", Icon && "font-mono text-sm", + error && "border-destructive focus-visible:ring-destructive", className )} data-testid={testId} diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx index 60838354..b2d76913 100644 --- a/apps/app/src/components/ui/branch-autocomplete.tsx +++ b/apps/app/src/components/ui/branch-autocomplete.tsx @@ -11,6 +11,7 @@ interface BranchAutocompleteProps { placeholder?: string; className?: string; disabled?: boolean; + error?: boolean; "data-testid"?: string; } @@ -21,6 +22,7 @@ export function BranchAutocomplete({ placeholder = "Select a branch...", className, disabled = false, + error = false, "data-testid": testId, }: BranchAutocompleteProps) { // Always include "main" at the top of suggestions @@ -43,6 +45,7 @@ export function BranchAutocomplete({ emptyMessage="No branches found." className={className} disabled={disabled} + error={error} icon={GitBranch} allowCreate createLabel={(v) => `Create "${v}"`} diff --git a/apps/app/src/components/ui/radio-group.tsx b/apps/app/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..a62d67dc --- /dev/null +++ b/apps/app/src/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; + diff --git a/apps/app/src/components/ui/switch.tsx b/apps/app/src/components/ui/switch.tsx new file mode 100644 index 00000000..24d00673 --- /dev/null +++ b/apps/app/src/components/ui/switch.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; + diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 3a0fb270..a0dd35f0 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { PointerSensor, useSensor, @@ -10,7 +10,9 @@ import { } from "@dnd-kit/core"; import { useAppStore, Feature } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; +import type { AutoModeEvent } from "@/types/electron"; import { pathsEqual } from "@/lib/utils"; +import { getBlockingDependencies } from "@/lib/dependency-resolver"; import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { RefreshCw } from "lucide-react"; import { useAutoMode } from "@/hooks/use-auto-mode"; @@ -25,7 +27,7 @@ import { AddFeatureDialog, AgentOutputModal, CompletedFeaturesModal, - DeleteAllVerifiedDialog, + ArchiveAllVerifiedDialog, DeleteCompletedFeatureDialog, EditFeatureDialog, FeatureSuggestionsDialog, @@ -72,6 +74,10 @@ export function BoardView() { setCurrentWorktree, getWorktrees, setWorktrees, + useWorktrees, + enableDependencyBlocking, + isPrimaryWorktreeBranch, + getPrimaryWorktreeBranch, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const { @@ -89,7 +95,7 @@ export function BoardView() { const [featuresWithContext, setFeaturesWithContext] = useState>( new Set() ); - const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = + const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false); const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false); @@ -277,6 +283,27 @@ export function BoardView() { const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({ currentProject }); + // Memoize the removed worktrees handler to prevent infinite loops + const handleRemovedWorktrees = useCallback( + (removedWorktrees: Array<{ path: string; branch: string }>) => { + // Reset features that were assigned to the removed worktrees (by branch) + hookFeatures.forEach((feature) => { + const matchesRemovedWorktree = removedWorktrees.some((removed) => { + // Match by branch name since worktreePath is no longer stored + return feature.branchName === removed.branch; + }); + + if (matchesRemovedWorktree) { + // Reset the feature's branch assignment + persistFeatureUpdate(feature.id, { + branchName: null as unknown as string | undefined, + }); + } + }); + }, + [hookFeatures, persistFeatureUpdate] + ); + // Get in-progress features for keyboard shortcuts (needed before actions hook) const inProgressFeaturesForShortcuts = useMemo(() => { return hookFeatures.filter((f) => { @@ -285,13 +312,12 @@ export function BoardView() { }); }, [hookFeatures, runningAutoTasks]); - // Get current worktree info (path and branch) for filtering features + // Get current worktree info (path) for filtering features // This needs to be before useBoardActions so we can pass currentWorktreeBranch const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreePath = currentWorktreeInfo?.path ?? null; - const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null; const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktrees = useMemo( () => @@ -301,8 +327,25 @@ export function BoardView() { [currentProject, worktreesByProject] ); + // Get the branch for the currently selected worktree + // Find the worktree that matches the current selection, or use main worktree + const selectedWorktree = useMemo(() => { + if (currentWorktreePath === null) { + // Primary worktree selected - find the main worktree + return worktrees.find((w) => w.isMain); + } else { + // Specific worktree selected - find it by path + return worktrees.find( + (w) => !w.isMain && pathsEqual(w.path, currentWorktreePath) + ); + } + }, [worktrees, currentWorktreePath]); + + // Get the current branch from the selected worktree (not from store which may be stale) + const currentWorktreeBranch = selectedWorktree?.branch ?? null; + // Get the branch for the currently selected worktree (for defaulting new features) - // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch + // Use the branch from selectedWorktree, or fall back to main worktree's branch const selectedWorktreeBranch = currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main"; @@ -326,7 +369,7 @@ export function BoardView() { handleOutputModalNumberKeyPress, handleForceStopFeature, handleStartNextFeatures, - handleDeleteAllVerified, + handleArchiveAllVerified, } = useBoardActions({ currentProject, features: hookFeatures, @@ -354,6 +397,202 @@ export function BoardView() { currentWorktreeBranch, }); + // Client-side auto mode: periodically check for backlog items and move them to in-progress + // Use a ref to track the latest auto mode state so async operations always check the current value + const autoModeRunningRef = useRef(autoMode.isRunning); + useEffect(() => { + autoModeRunningRef.current = autoMode.isRunning; + }, [autoMode.isRunning]); + + // Track features that are pending (started but not yet confirmed running) + const pendingFeaturesRef = useRef>(new Set()); + + // Listen to auto mode events to remove features from pending when they start running + useEffect(() => { + const api = getElectronAPI(); + if (!api?.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + if (!currentProject) return; + + // Only process events for the current project + const eventProjectPath = + "projectPath" in event ? event.projectPath : undefined; + if (eventProjectPath && eventProjectPath !== currentProject.path) { + return; + } + + switch (event.type) { + case "auto_mode_feature_start": + // Feature is now confirmed running - remove from pending + if (event.featureId) { + pendingFeaturesRef.current.delete(event.featureId); + } + break; + + case "auto_mode_feature_complete": + case "auto_mode_error": + // Feature completed or errored - remove from pending if still there + if (event.featureId) { + pendingFeaturesRef.current.delete(event.featureId); + } + break; + } + }); + + return unsubscribe; + }, [currentProject]); + + useEffect(() => { + if (!autoMode.isRunning || !currentProject) { + return; + } + + let isChecking = false; + let isActive = true; // Track if this effect is still active + + const checkAndStartFeatures = async () => { + // Check if auto mode is still running and effect is still active + // Use ref to get the latest value, not the closure value + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Prevent concurrent executions + if (isChecking) { + return; + } + + isChecking = true; + try { + // Double-check auto mode is still running before proceeding + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Count currently running tasks + pending features + const currentRunning = + runningAutoTasks.length + pendingFeaturesRef.current.size; + const availableSlots = maxConcurrency - currentRunning; + + // No available slots, skip check + if (availableSlots <= 0) { + return; + } + + // Filter backlog features by the currently selected worktree branch + const primaryBranch = currentProject.path + ? getPrimaryWorktreeBranch(currentProject.path) + : null; + const backlogFeatures = hookFeatures.filter((f) => { + if (f.status !== "backlog") return false; + + // Determine the feature's branch (default to primary branch if not set) + const featureBranch = f.branchName || primaryBranch || "main"; + + // If no worktree is selected (currentWorktreeBranch is null or matches primary), + // show features with no branch or primary branch + if ( + !currentWorktreeBranch || + (currentProject.path && + isPrimaryWorktreeBranch( + currentProject.path, + currentWorktreeBranch + )) + ) { + return ( + !f.branchName || + (currentProject.path && + isPrimaryWorktreeBranch(currentProject.path, featureBranch)) + ); + } + + // Otherwise, only show features matching the selected worktree branch + return featureBranch === currentWorktreeBranch; + }); + + if (backlogFeatures.length === 0) { + return; + } + + // Sort by priority (lower number = higher priority, priority 1 is highest) + const sortedBacklog = [...backlogFeatures].sort( + (a, b) => (a.priority || 999) - (b.priority || 999) + ); + + // Filter out features with blocking dependencies if dependency blocking is enabled + const eligibleFeatures = enableDependencyBlocking + ? sortedBacklog.filter((f) => { + const blockingDeps = getBlockingDependencies(f, hookFeatures); + return blockingDeps.length === 0; + }) + : sortedBacklog; + + // Start features up to available slots + const featuresToStart = eligibleFeatures.slice(0, availableSlots); + + for (const feature of featuresToStart) { + // Check again before starting each feature + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Simplified: No worktree creation on client - server derives workDir from feature.branchName + // If feature has no branchName and primary worktree is selected, assign primary branch + if (currentWorktreePath === null && !feature.branchName) { + const primaryBranch = + (currentProject.path + ? getPrimaryWorktreeBranch(currentProject.path) + : null) || "main"; + await persistFeatureUpdate(feature.id, { + branchName: primaryBranch, + }); + } + + // Final check before starting implementation + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Start the implementation - server will derive workDir from feature.branchName + const started = await handleStartImplementation(feature); + + // If successfully started, track it as pending until we receive the start event + if (started) { + pendingFeaturesRef.current.add(feature.id); + } + } + } finally { + isChecking = false; + } + }; + + // Check immediately, then every 3 seconds + checkAndStartFeatures(); + const interval = setInterval(checkAndStartFeatures, 3000); + + return () => { + // Mark as inactive to prevent any pending async operations from continuing + isActive = false; + clearInterval(interval); + // Clear pending features when effect unmounts or dependencies change + pendingFeaturesRef.current.clear(); + }; + }, [ + autoMode.isRunning, + currentProject, + runningAutoTasks, + maxConcurrency, + hookFeatures, + currentWorktreeBranch, + currentWorktreePath, + getPrimaryWorktreeBranch, + isPrimaryWorktreeBranch, + enableDependencyBlocking, + persistFeatureUpdate, + handleStartImplementation, + ]); + // Use keyboard shortcuts hook (after actions hook) useBoardKeyboardShortcuts({ features: hookFeatures, @@ -422,8 +661,13 @@ export function BoardView() { maxConcurrency={maxConcurrency} onConcurrencyChange={setMaxConcurrency} isAutoModeRunning={autoMode.isRunning} - onStartAutoMode={() => autoMode.start()} - onStopAutoMode={() => autoMode.stop()} + onAutoModeToggle={(enabled) => { + if (enabled) { + autoMode.start(); + } else { + autoMode.stop(); + } + }} onAddFeature={() => setShowAddDialog(true)} addFeatureShortcut={{ key: shortcuts.addFeature, @@ -454,10 +698,10 @@ export function BoardView() { setSelectedWorktreeForAction(worktree); setShowCreateBranchDialog(true); }} + onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} features={hookFeatures.map((f) => ({ id: f.id, - worktreePath: f.worktreePath, branchName: f.branchName, }))} /> @@ -512,7 +756,7 @@ export function BoardView() { onStartNextFeatures={handleStartNextFeatures} onShowSuggestions={() => setShowSuggestionsDialog(true)} suggestionsCount={suggestionsCount} - onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} /> @@ -552,6 +796,7 @@ export function BoardView() { branchSuggestions={branchSuggestions} defaultSkipTests={defaultSkipTests} defaultBranch={selectedWorktreeBranch} + currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -564,6 +809,7 @@ export function BoardView() { onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -580,14 +826,14 @@ export function BoardView() { onNumberKeyPress={handleOutputModalNumberKeyPress} /> - {/* Delete All Verified Dialog */} - { - await handleDeleteAllVerified(); - setShowDeleteAllVerifiedDialog(false); + await handleArchiveAllVerified(); + setShowArchiveAllVerifiedDialog(false); }} /> @@ -657,19 +903,13 @@ export function BoardView() { projectPath={currentProject.path} worktree={selectedWorktreeForAction} onDeleted={(deletedWorktree, _deletedBranch) => { - // Reset features that were assigned to the deleted worktree + // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { - const matchesByPath = - feature.worktreePath && - pathsEqual(feature.worktreePath, deletedWorktree.path); - const matchesByBranch = - feature.branchName === deletedWorktree.branch; - - if (matchesByPath || matchesByBranch) { - // Reset the feature's worktree assignment + // Match by branch name since worktreePath is no longer stored + if (feature.branchName === deletedWorktree.branch) { + // Reset the feature's branch assignment persistFeatureUpdate(feature.id, { branchName: null as unknown as string | undefined, - worktreePath: null as unknown as string | undefined, }); } }); diff --git a/apps/app/src/components/views/board-view/board-header.tsx b/apps/app/src/components/views/board-view/board-header.tsx index 844abd8d..4340ff48 100644 --- a/apps/app/src/components/views/board-view/board-header.tsx +++ b/apps/app/src/components/views/board-view/board-header.tsx @@ -3,7 +3,9 @@ import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Slider } from "@/components/ui/slider"; -import { Play, StopCircle, Plus, Users } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Plus, Users } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; interface BoardHeaderProps { @@ -11,8 +13,7 @@ interface BoardHeaderProps { maxConcurrency: number; onConcurrencyChange: (value: number) => void; isAutoModeRunning: boolean; - onStartAutoMode: () => void; - onStopAutoMode: () => void; + onAutoModeToggle: (enabled: boolean) => void; onAddFeature: () => void; addFeatureShortcut: KeyboardShortcut; isMounted: boolean; @@ -23,8 +24,7 @@ export function BoardHeader({ maxConcurrency, onConcurrencyChange, isAutoModeRunning, - onStartAutoMode, - onStopAutoMode, + onAutoModeToggle, onAddFeature, addFeatureShortcut, isMounted, @@ -63,29 +63,20 @@ export function BoardHeader({ {/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {isMounted && ( - <> - {isAutoModeRunning ? ( - - ) : ( - - )} - +
+ + +
)} void; categorySuggestions: string[]; branchSuggestions: string[]; defaultSkipTests: boolean; defaultBranch?: string; + currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; @@ -83,10 +85,12 @@ export function AddFeatureDialog({ branchSuggestions, defaultSkipTests, defaultBranch = "main", + currentBranch, isMaximized, showProfilesOnly, aiProfiles, }: AddFeatureDialogProps) { + const [useCurrentBranch, setUseCurrentBranch] = useState(true); const [newFeature, setNewFeature] = useState({ category: "", description: "", @@ -96,7 +100,7 @@ export function AddFeatureDialog({ skipTests: false, model: "opus" as AgentModel, thinkingLevel: "none" as ThinkingLevel, - branchName: "main", + branchName: "", priority: 2 as number, // Default to medium priority }); const [newFeaturePreviewMap, setNewFeaturePreviewMap] = @@ -117,8 +121,9 @@ export function AddFeatureDialog({ setNewFeature((prev) => ({ ...prev, skipTests: defaultSkipTests, - branchName: defaultBranch, + branchName: defaultBranch || "", })); + setUseCurrentBranch(true); } }, [open, defaultSkipTests, defaultBranch]); @@ -128,12 +133,24 @@ export function AddFeatureDialog({ return; } + // Validate branch selection when "other branch" is selected + if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) { + toast.error("Please select a branch name"); + return; + } + const category = newFeature.category || "Uncategorized"; const selectedModel = newFeature.model; const normalizedThinking = modelSupportsThinking(selectedModel) ? newFeature.thinkingLevel : "none"; + // Use current branch if toggle is on (empty string = use current), otherwise use selected branch + // Important: Don't save the actual current branch name - empty string means "use current" + const finalBranchName = useCurrentBranch + ? "" + : newFeature.branchName || ""; + onAdd({ category, description: newFeature.description, @@ -143,7 +160,7 @@ export function AddFeatureDialog({ skipTests: newFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, - branchName: newFeature.branchName, + branchName: finalBranchName, priority: newFeature.priority, }); @@ -158,8 +175,9 @@ export function AddFeatureDialog({ model: "opus", priority: 2, thinkingLevel: "none", - branchName: defaultBranch, + branchName: "", }); + setUseCurrentBranch(true); setNewFeaturePreviewMap(new Map()); setShowAdvancedOptions(false); setDescriptionError(false); @@ -359,22 +377,17 @@ export function AddFeatureDialog({ /> {useWorktrees && ( -
- - - setNewFeature({ ...newFeature, branchName: value }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="feature-branch-input" - /> -

- Work will be done in this branch. A worktree will be created if - needed. -

-
+ + setNewFeature({ ...newFeature, branchName: value }) + } + branchSuggestions={branchSuggestions} + currentBranch={currentBranch} + testIdPrefix="feature" + /> )} {/* Priority Selector */} @@ -477,6 +490,11 @@ export function AddFeatureDialog({ hotkey={{ key: "Enter", cmdCtrl: true }} hotkeyActive={open} data-testid="confirm-add-feature" + disabled={ + useWorktrees && + !useCurrentBranch && + !newFeature.branchName.trim() + } > Add Feature
diff --git a/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx new file mode 100644 index 00000000..66674648 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Archive } from "lucide-react"; + +interface ArchiveAllVerifiedDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + verifiedCount: number; + onConfirm: () => void; +} + +export function ArchiveAllVerifiedDialog({ + open, + onOpenChange, + verifiedCount, + onConfirm, +}: ArchiveAllVerifiedDialogProps) { + return ( + + + + 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..31f58103 100644 --- a/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -53,14 +53,18 @@ export function CreatePRDialog({ // Reset state when dialog opens or worktree changes useEffect(() => { if (open) { - // Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback) - // These are set by the API response and should persist until dialog closes + // Reset form fields setTitle(""); setBody(""); setCommitMessage(""); setBaseBranch("main"); setIsDraft(false); setError(null); + // Also reset result states when opening for a new worktree + // This prevents showing stale PR URLs from previous worktrees + setPrUrl(null); + setBrowserUrl(null); + setShowBrowserFallback(false); } else { // Reset everything when dialog closes setTitle(""); @@ -105,7 +109,8 @@ export function CreatePRDialog({ onClick: () => window.open(result.result!.prUrl!, "_blank"), }, }); - onCreated(); + // Don't call onCreated() here - keep dialog open to show success message + // onCreated() will be called when user closes the dialog } else { // Branch was pushed successfully const prError = result.result.prError; @@ -182,6 +187,9 @@ export function CreatePRDialog({ }; const handleClose = () => { + // Call onCreated() to refresh worktrees when dialog closes + // This ensures the worktree list is updated after any operation + onCreated(); onOpenChange(false); // Reset state after dialog closes setTimeout(() => { @@ -228,13 +236,18 @@ export function CreatePRDialog({ Your PR is ready for review

- +
+ + +
) : shouldShowBrowserFallback ? (
diff --git a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 4fed0b13..e02a499e 100644 --- a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -44,6 +44,7 @@ import { ProfileQuickSelect, TestingTabContent, PrioritySelector, + BranchSelector, } from "../shared"; import { DropdownMenu, @@ -66,12 +67,13 @@ interface EditFeatureDialogProps { model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; - branchName: string; + branchName: string; // Can be empty string to use current branch priority: number; } ) => void; categorySuggestions: string[]; branchSuggestions: string[]; + currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; @@ -84,12 +86,17 @@ export function EditFeatureDialog({ onUpdate, categorySuggestions, branchSuggestions, + currentBranch, isMaximized, showProfilesOnly, aiProfiles, allFeatures, }: EditFeatureDialogProps) { const [editingFeature, setEditingFeature] = useState(feature); + const [useCurrentBranch, setUseCurrentBranch] = useState(() => { + // If feature has no branchName, default to using current branch + return !feature?.branchName; + }); const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState(() => new Map()); const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); @@ -107,12 +114,27 @@ export function EditFeatureDialog({ if (!feature) { setEditFeaturePreviewMap(new Map()); setShowEditAdvancedOptions(false); + } else { + // If feature has no branchName, default to using current branch + setUseCurrentBranch(!feature.branchName); } }, [feature]); const handleUpdate = () => { if (!editingFeature) return; + // Validate branch selection when "other branch" is selected and branch selector is enabled + const isBranchSelectorEnabled = editingFeature.status === "backlog"; + if ( + useWorktrees && + isBranchSelectorEnabled && + !useCurrentBranch && + !editingFeature.branchName?.trim() + ) { + toast.error("Please select a branch name"); + return; + } + const selectedModel = (editingFeature.model ?? "opus") as AgentModel; const normalizedThinking: ThinkingLevel = modelSupportsThinking( selectedModel @@ -120,6 +142,12 @@ export function EditFeatureDialog({ ? editingFeature.thinkingLevel ?? "none" : "none"; + // Use current branch if toggle is on (empty string = use current), otherwise use selected branch + // Important: Don't save the actual current branch name - empty string means "use current" + const finalBranchName = useCurrentBranch + ? "" + : editingFeature.branchName || ""; + const updates = { category: editingFeature.category, description: editingFeature.description, @@ -128,7 +156,7 @@ export function EditFeatureDialog({ model: selectedModel, thinkingLevel: normalizedThinking, imagePaths: editingFeature.imagePaths ?? [], - branchName: editingFeature.branchName ?? "main", + branchName: finalBranchName, priority: editingFeature.priority ?? 2, }; @@ -339,33 +367,21 @@ export function EditFeatureDialog({ />
{useWorktrees && ( -
- - - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="edit-feature-branch" - disabled={editingFeature.status !== "backlog"} - /> - {editingFeature.status !== "backlog" && ( -

- Branch cannot be changed after work has started. -

- )} - {editingFeature.status === "backlog" && ( -

- Work will be done in this branch. A worktree will be created - if needed. -

- )} -
+ + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branchSuggestions={branchSuggestions} + currentBranch={currentBranch} + disabled={editingFeature.status !== "backlog"} + testIdPrefix="edit-feature" + /> )} {/* Priority Selector */} @@ -486,6 +502,12 @@ export function EditFeatureDialog({ hotkey={{ key: "Enter", cmdCtrl: true }} hotkeyActive={!!editingFeature} data-testid="confirm-edit-feature" + disabled={ + useWorktrees && + editingFeature.status === "backlog" && + !useCurrentBranch && + !editingFeature.branchName?.trim() + } > Save Changes diff --git a/apps/app/src/components/views/board-view/dialogs/index.ts b/apps/app/src/components/views/board-view/dialogs/index.ts index 5685ddcb..92575a32 100644 --- a/apps/app/src/components/views/board-view/dialogs/index.ts +++ b/apps/app/src/components/views/board-view/dialogs/index.ts @@ -1,7 +1,7 @@ export { AddFeatureDialog } from "./add-feature-dialog"; export { AgentOutputModal } from "./agent-output-modal"; export { CompletedFeaturesModal } from "./completed-features-modal"; -export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog"; +export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog"; export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog"; export { EditFeatureDialog } from "./edit-feature-dialog"; export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts index 8a5f2d04..9eb10208 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts @@ -76,67 +76,13 @@ export function useBoardActions({ moveFeature, useWorktrees, enableDependencyBlocking, + isPrimaryWorktreeBranch, + getPrimaryWorktreeBranch, } = useAppStore(); const autoMode = useAutoMode(); - /** - * Get or create the worktree path for a feature based on its branchName. - * - If branchName is "main" or empty, returns the project path - * - Otherwise, creates a worktree for that branch if needed - */ - const getOrCreateWorktreeForFeature = useCallback( - async (feature: Feature): Promise => { - if (!projectPath) return null; - - const branchName = feature.branchName || "main"; - - // If targeting main branch, use the project path directly - if (branchName === "main" || branchName === "master") { - return projectPath; - } - - // For other branches, create a worktree if it doesn't exist - try { - const api = getElectronAPI(); - if (!api?.worktree?.create) { - console.error("[BoardActions] Worktree API not available"); - return projectPath; - } - - // Try to create the worktree (will return existing if already exists) - const result = await api.worktree.create(projectPath, branchName); - - if (result.success && result.worktree) { - console.log( - `[BoardActions] Worktree ready for branch "${branchName}": ${result.worktree.path}` - ); - if (result.worktree.isNew) { - toast.success(`Worktree created for branch "${branchName}"`, { - description: "A new worktree was created for this feature.", - }); - } - return result.worktree.path; - } else { - console.error( - "[BoardActions] Failed to create worktree:", - result.error - ); - toast.error("Failed to create worktree", { - description: - result.error || "Could not create worktree for this branch.", - }); - return projectPath; // Fall back to project path - } - } catch (error) { - console.error("[BoardActions] Error creating worktree:", error); - toast.error("Error creating worktree", { - description: error instanceof Error ? error.message : "Unknown error", - }); - return projectPath; // Fall back to project path - } - }, - [projectPath] - ); + // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side + // at execution time based on feature.branchName const handleAddFeature = useCallback( async (featureData: { @@ -151,34 +97,26 @@ export function useBoardActions({ branchName: string; priority: number; }) => { - let worktreePath: string | undefined; - - // If worktrees are enabled and a non-main branch is selected, create the worktree - if (useWorktrees && featureData.branchName) { - const branchName = featureData.branchName; - if (branchName !== "main" && branchName !== "master") { - // Create a temporary feature-like object for getOrCreateWorktreeForFeature - const tempFeature = { branchName } as Feature; - const path = await getOrCreateWorktreeForFeature(tempFeature); - if (path && path !== projectPath) { - worktreePath = path; - // Refresh worktree selector after creating worktree - onWorktreeCreated?.(); - } - } - } + // Simplified: Only store branchName, no worktree creation on add + // Worktrees are created at execution time (when feature starts) + // Empty string means user chose "use current branch" - don't save a branch name + const finalBranchName = + featureData.branchName === "" + ? undefined + : featureData.branchName || undefined; const newFeatureData = { ...featureData, status: "backlog" as const, - worktreePath, + branchName: finalBranchName, + // No worktreePath - derived at runtime from branchName }; const createdFeature = addFeature(newFeatureData); // Must await to ensure feature exists on server before user can drag it await persistFeatureCreate(createdFeature); saveCategory(featureData.category); }, - [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] + [addFeature, persistFeatureCreate, saveCategory] ); const handleUpdateFeature = useCallback( @@ -196,44 +134,13 @@ export function useBoardActions({ priority: number; } ) => { - // Get the current feature to check if branch is changing - const currentFeature = features.find((f) => f.id === featureId); - const currentBranch = currentFeature?.branchName || "main"; - const newBranch = updates.branchName || "main"; - const branchIsChanging = currentBranch !== newBranch; + const finalBranchName = + updates.branchName === "" ? undefined : updates.branchName || undefined; - let worktreePath: string | undefined; - let shouldClearWorktreePath = false; - - // If worktrees are enabled and branch is changing to a non-main branch, create worktree - if (useWorktrees && branchIsChanging) { - if (newBranch === "main" || newBranch === "master") { - // Changing to main - clear the worktreePath - shouldClearWorktreePath = true; - } else { - // Changing to a feature branch - create worktree if needed - const tempFeature = { branchName: newBranch } as Feature; - const path = await getOrCreateWorktreeForFeature(tempFeature); - if (path && path !== projectPath) { - worktreePath = path; - // Refresh worktree selector after creating worktree - onWorktreeCreated?.(); - } - } - } - - // Build final updates with worktreePath if it was changed - let finalUpdates: typeof updates & { worktreePath?: string }; - if (branchIsChanging && useWorktrees) { - if (shouldClearWorktreePath) { - // Use null to clear the value in persistence (cast to work around type system) - finalUpdates = { ...updates, worktreePath: null as unknown as string | undefined }; - } else { - finalUpdates = { ...updates, worktreePath }; - } - } else { - finalUpdates = updates; - } + const finalUpdates = { + ...updates, + branchName: finalBranchName, + }; updateFeature(featureId, finalUpdates); persistFeatureUpdate(featureId, finalUpdates); @@ -242,7 +149,7 @@ export function useBoardActions({ } setEditingFeature(null); }, - [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] ); const handleDeleteFeature = useCallback( @@ -307,21 +214,18 @@ export function useBoardActions({ return; } - // Use the feature's assigned worktreePath (set when moving to in_progress) - // This ensures work happens in the correct worktree based on the feature's branchName - const featureWorktreePath = feature.worktreePath; - + // Server derives workDir from feature.branchName at execution time const result = await api.autoMode.runFeature( currentProject.path, feature.id, - useWorktrees, - featureWorktreePath || undefined + useWorktrees + // No worktreePath - server derives from feature.branchName ); if (result.success) { console.log( - "[Board] Feature run started successfully in worktree:", - featureWorktreePath || "main" + "[Board] Feature run started successfully, branch:", + feature.branchName || "default" ); } else { console.error("[Board] Failed to run feature:", result.error); @@ -350,10 +254,12 @@ export function useBoardActions({ if (enableDependencyBlocking) { const blockingDeps = getBlockingDependencies(feature, features); if (blockingDeps.length > 0) { - const depDescriptions = blockingDeps.map(depId => { - const dep = features.find(f => f.id === depId); - return dep ? truncateDescription(dep.description, 40) : depId; - }).join(", "); + const depDescriptions = blockingDeps + .map((depId) => { + const dep = features.find((f) => f.id === depId); + return dep ? truncateDescription(dep.description, 40) : depId; + }) + .join(", "); toast.warning("Starting feature with incomplete dependencies", { description: `This feature depends on: ${depDescriptions}`, @@ -372,7 +278,14 @@ export function useBoardActions({ await handleRunFeature(feature); return true; }, - [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature] + [ + autoMode, + enableDependencyBlocking, + features, + updateFeature, + persistFeatureUpdate, + handleRunFeature, + ] ); const handleVerifyFeature = useCallback( @@ -489,7 +402,6 @@ export function useBoardActions({ const featureId = followUpFeature.id; const featureDescription = followUpFeature.description; - const prompt = followUpPrompt; const api = getElectronAPI(); if (!api?.autoMode?.followUpFeature) { @@ -521,15 +433,14 @@ export function useBoardActions({ }); const imagePaths = followUpImagePaths.map((img) => img.path); - // Use the feature's worktreePath to ensure work happens in the correct branch - const featureWorktreePath = followUpFeature.worktreePath; + // Server derives workDir from feature.branchName at execution time api.autoMode .followUpFeature( currentProject.path, followUpFeature.id, followUpPrompt, - imagePaths, - featureWorktreePath + imagePaths + // No worktreePath - server derives from feature.branchName ) .catch((error) => { console.error("[Board] Error sending follow-up:", error); @@ -569,11 +480,11 @@ export function useBoardActions({ return; } - // Pass the feature's worktreePath to ensure commits happen in the correct worktree + // Server derives workDir from feature.branchName const result = await api.autoMode.commitFeature( currentProject.path, - feature.id, - feature.worktreePath + feature.id + // No worktreePath - server derives from feature.branchName ); if (result.success) { @@ -758,23 +669,25 @@ export function useBoardActions({ const handleStartNextFeatures = useCallback(async () => { // Filter backlog features by the currently selected worktree branch // This ensures "G" only starts features from the filtered list + const primaryBranch = projectPath + ? getPrimaryWorktreeBranch(projectPath) + : null; const backlogFeatures = features.filter((f) => { if (f.status !== "backlog") return false; - // Determine the feature's branch (default to "main" if not set) - const featureBranch = f.branchName || "main"; + // Determine the feature's branch (default to primary branch if not set) + const featureBranch = f.branchName || primaryBranch || "main"; - // If no worktree is selected (currentWorktreeBranch is null or main-like), - // show features with no branch or "main"/"master" branch + // If no worktree is selected (currentWorktreeBranch is null or matches primary), + // show features with no branch or primary branch if ( !currentWorktreeBranch || - currentWorktreeBranch === "main" || - currentWorktreeBranch === "master" + (projectPath && + isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch)) ) { return ( !f.branchName || - featureBranch === "main" || - featureBranch === "master" + (projectPath && isPrimaryWorktreeBranch(projectPath, featureBranch)) ); } @@ -794,57 +707,65 @@ export function useBoardActions({ } if (backlogFeatures.length === 0) { + const isOnPrimaryBranch = + !currentWorktreeBranch || + (projectPath && + isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch)); toast.info("Backlog empty", { - description: - currentWorktreeBranch && - currentWorktreeBranch !== "main" && - currentWorktreeBranch !== "master" - ? `No features in backlog for branch "${currentWorktreeBranch}".` - : "No features in backlog to start.", + description: !isOnPrimaryBranch + ? `No features in backlog for branch "${currentWorktreeBranch}".` + : "No features in backlog to start.", }); return; } // Sort by priority (lower number = higher priority, priority 1 is highest) - // This matches the auto mode service behavior for consistency - const sortedBacklog = [...backlogFeatures].sort( - (a, b) => (a.priority || 999) - (b.priority || 999) - ); + // Features with blocking dependencies are sorted to the end + const sortedBacklog = [...backlogFeatures].sort((a, b) => { + const aBlocked = enableDependencyBlocking + ? getBlockingDependencies(a, features).length > 0 + : false; + const bBlocked = enableDependencyBlocking + ? getBlockingDependencies(b, features).length > 0 + : false; + + // Blocked features go to the end + if (aBlocked && !bBlocked) return 1; + if (!aBlocked && bBlocked) return -1; + + // Within same blocked/unblocked group, sort by priority + return (a.priority || 999) - (b.priority || 999); + }); + + // Find the first feature without blocking dependencies + const featureToStart = sortedBacklog.find((f) => { + if (!enableDependencyBlocking) return true; + return getBlockingDependencies(f, features).length === 0; + }); + + if (!featureToStart) { + toast.info("No eligible features", { + description: + "All backlog features have unmet dependencies. Complete their dependencies first.", + }); + return; + } // Start only one feature per keypress (user must press again for next) - const featuresToStart = sortedBacklog.slice(0, 1); - - for (const feature of featuresToStart) { - // Only create worktrees if the feature is enabled - let worktreePath: string | null = null; - if (useWorktrees) { - // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress) - worktreePath = await getOrCreateWorktreeForFeature(feature); - if (worktreePath) { - await persistFeatureUpdate(feature.id, { worktreePath }); - } - // Refresh worktree selector after creating worktree - onWorktreeCreated?.(); - } - // Start the implementation - // Pass feature with worktreePath so handleRunFeature uses the correct path - await handleStartImplementation({ - ...feature, - worktreePath: worktreePath || undefined, - }); - } + // Simplified: No worktree creation on client - server derives workDir from feature.branchName + await handleStartImplementation(featureToStart); }, [ features, runningAutoTasks, handleStartImplementation, - getOrCreateWorktreeForFeature, - persistFeatureUpdate, - onWorktreeCreated, currentWorktreeBranch, - useWorktrees, + projectPath, + isPrimaryWorktreeBranch, + getPrimaryWorktreeBranch, + enableDependencyBlocking, ]); - const handleDeleteAllVerified = useCallback(async () => { + const handleArchiveAllVerified = useCallback(async () => { const verifiedFeatures = features.filter((f) => f.status === "verified"); for (const feature of verifiedFeatures) { @@ -853,22 +774,29 @@ export function useBoardActions({ try { await autoMode.stopFeature(feature.id); } catch (error) { - console.error("[Board] Error stopping feature before delete:", error); + console.error( + "[Board] Error stopping feature before archive:", + error + ); } } - removeFeature(feature.id); - persistFeatureDelete(feature.id); + // Archive the feature by setting status to completed + const updates = { + status: "completed" as const, + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } - toast.success("All verified features deleted", { - description: `Deleted ${verifiedFeatures.length} feature(s).`, + toast.success("All verified features archived", { + description: `Archived ${verifiedFeatures.length} feature(s).`, }); }, [ features, runningAutoTasks, autoMode, - removeFeature, - persistFeatureDelete, + updateFeature, + persistFeatureUpdate, ]); return { @@ -890,6 +818,6 @@ export function useBoardActions({ handleOutputModalNumberKeyPress, handleForceStopFeature, handleStartNextFeatures, - handleDeleteAllVerified, + handleArchiveAllVerified, }; } diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts index f09a0135..24810f41 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts @@ -1,7 +1,6 @@ import { useMemo, useCallback } from "react"; -import { Feature } from "@/store/app-store"; -import { resolveDependencies } from "@/lib/dependency-resolver"; -import { pathsEqual } from "@/lib/utils"; +import { Feature, useAppStore } from "@/store/app-store"; +import { resolveDependencies, getBlockingDependencies } from "@/lib/dependency-resolver"; type ColumnId = Feature["status"]; @@ -56,26 +55,24 @@ export function useBoardColumnFeatures({ // If feature has a running agent, always show it in "in_progress" const isRunning = runningAutoTasks.includes(f.id); - // Check if feature matches the current worktree - // Match by worktreePath if set, OR by branchName if set - // Features with neither are considered unassigned (show on ALL worktrees) - const featureBranch = f.branchName || "main"; - const hasWorktreeAssigned = f.worktreePath || f.branchName; + // Check if feature matches the current worktree by branchName + // Features without branchName are considered unassigned (show only on primary worktree) + const featureBranch = f.branchName; let matchesWorktree: boolean; - if (!hasWorktreeAssigned) { - // No worktree or branch assigned - show on ALL worktrees (unassigned) - matchesWorktree = true; - } else if (f.worktreePath) { - // Has worktreePath - match by path (use pathsEqual for cross-platform compatibility) - matchesWorktree = pathsEqual(f.worktreePath, effectiveWorktreePath); + if (!featureBranch) { + // No branch assigned - show only on primary worktree + const isViewingPrimary = currentWorktreePath === null; + matchesWorktree = isViewingPrimary; } else if (effectiveBranch === null) { // We're viewing main but branch hasn't been initialized yet // (worktrees disabled or haven't loaded yet). - // Show features assigned to main/master branch since we're on the main worktree. - matchesWorktree = featureBranch === "main" || featureBranch === "master"; + // Show features assigned to primary worktree's branch. + matchesWorktree = projectPath + ? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch) + : false; } else { - // Has branchName but no worktreePath - match by branch name + // Match by branch name matchesWorktree = featureBranch === effectiveBranch; } @@ -111,7 +108,29 @@ export function useBoardColumnFeatures({ // Within the same dependency level, features are sorted by priority if (map.backlog.length > 0) { const { orderedFeatures } = resolveDependencies(map.backlog); - map.backlog = orderedFeatures; + + // Get all features to check blocking dependencies against + const allFeatures = features; + const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking; + + // Sort blocked features to the end of the backlog + // This keeps the dependency order within each group (unblocked/blocked) + if (enableDependencyBlocking) { + const unblocked: Feature[] = []; + const blocked: Feature[] = []; + + for (const f of orderedFeatures) { + if (getBlockingDependencies(f, allFeatures).length > 0) { + blocked.push(f); + } else { + unblocked.push(f); + } + } + + map.backlog = [...unblocked, ...blocked]; + } else { + map.backlog = orderedFeatures; + } } return map; diff --git a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts index e9016a8e..92366e5b 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -4,7 +4,6 @@ import { Feature } from "@/store/app-store"; import { useAppStore } from "@/store/app-store"; import { toast } from "sonner"; import { COLUMNS, ColumnId } from "../constants"; -import { getElectronAPI } from "@/lib/electron"; interface UseBoardDragDropProps { features: Feature[]; @@ -29,62 +28,10 @@ export function useBoardDragDrop({ onWorktreeCreated, }: UseBoardDragDropProps) { const [activeFeature, setActiveFeature] = useState(null); - const { moveFeature, useWorktrees } = useAppStore(); + const { moveFeature } = useAppStore(); - /** - * Get or create the worktree path for a feature based on its branchName. - * - If branchName is "main" or empty, returns the project path - * - Otherwise, creates a worktree for that branch if needed - */ - const getOrCreateWorktreeForFeature = useCallback( - async (feature: Feature): Promise => { - if (!projectPath) return null; - - const branchName = feature.branchName || "main"; - - // If targeting main branch, use the project path directly - if (branchName === "main" || branchName === "master") { - return projectPath; - } - - // For other branches, create a worktree if it doesn't exist - try { - const api = getElectronAPI(); - if (!api?.worktree?.create) { - console.error("[DragDrop] Worktree API not available"); - return projectPath; - } - - // Try to create the worktree (will return existing if already exists) - const result = await api.worktree.create(projectPath, branchName); - - if (result.success && result.worktree) { - console.log( - `[DragDrop] Worktree ready for branch "${branchName}": ${result.worktree.path}` - ); - if (result.worktree.isNew) { - toast.success(`Worktree created for branch "${branchName}"`, { - description: "A new worktree was created for this feature.", - }); - } - return result.worktree.path; - } else { - console.error("[DragDrop] Failed to create worktree:", result.error); - toast.error("Failed to create worktree", { - description: result.error || "Could not create worktree for this branch.", - }); - return projectPath; // Fall back to project path - } - } catch (error) { - console.error("[DragDrop] Error creating worktree:", error); - toast.error("Error creating worktree", { - description: error instanceof Error ? error.message : "Unknown error", - }); - return projectPath; // Fall back to project path - } - }, - [projectPath] - ); + // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side + // at execution time based on feature.branchName const handleDragStart = useCallback( (event: DragStartEvent) => { @@ -118,17 +65,13 @@ export function useBoardDragDrop({ // - Backlog items can always be dragged // - waiting_approval items can always be dragged (to allow manual verification via drag) // - verified items can always be dragged (to allow moving back to waiting_approval) - // - skipTests (non-TDD) items can be dragged between in_progress and verified - // - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running) - if ( - draggedFeature.status !== "backlog" && - draggedFeature.status !== "waiting_approval" && - draggedFeature.status !== "verified" - ) { - // Only allow dragging in_progress if it's a skipTests feature and not currently running - if (!draggedFeature.skipTests || isRunningTask) { + // - in_progress items can be dragged (but not if they're currently running) + // - Non-skipTests (TDD) items that are in progress cannot be dragged if they are running + if (draggedFeature.status === "in_progress") { + // Only allow dragging in_progress if it's not currently running + if (isRunningTask) { console.log( - "[Board] Cannot drag feature - TDD feature or currently running" + "[Board] Cannot drag feature - currently running" ); return; } @@ -154,23 +97,13 @@ export function useBoardDragDrop({ if (targetStatus === draggedFeature.status) return; // Handle different drag scenarios + // Note: Worktrees are created server-side at execution time based on feature.branchName if (draggedFeature.status === "backlog") { // From backlog if (targetStatus === "in_progress") { - // Only create worktrees if the feature is enabled - let worktreePath: string | null = null; - if (useWorktrees) { - // Get or create worktree based on the feature's assigned branch - worktreePath = await getOrCreateWorktreeForFeature(draggedFeature); - if (worktreePath) { - await persistFeatureUpdate(featureId, { worktreePath }); - } - // Refresh worktree selector after moving to in_progress - onWorktreeCreated?.(); - } // Use helper function to handle concurrency check and start implementation - // Pass feature with worktreePath so handleRunFeature uses the correct path - await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined }); + // Server will derive workDir from feature.branchName + await handleStartImplementation(draggedFeature); } else { moveFeature(featureId, targetStatus); persistFeatureUpdate(featureId, { status: targetStatus }); @@ -195,11 +128,10 @@ export function useBoardDragDrop({ } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); - // Clear justFinishedAt timestamp and worktreePath when moving back to backlog + // Clear justFinishedAt timestamp when moving back to backlog persistFeatureUpdate(featureId, { status: "backlog", justFinishedAt: undefined, - worktreePath: undefined, }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( @@ -208,13 +140,23 @@ export function useBoardDragDrop({ )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } - } else if (draggedFeature.skipTests) { - // skipTests feature being moved between in_progress and verified - if ( + } else if (draggedFeature.status === "in_progress") { + // Handle in_progress features being moved + if (targetStatus === "backlog") { + // Allow moving in_progress cards back to backlog + moveFeature(featureId, "backlog"); + persistFeatureUpdate(featureId, { status: "backlog" }); + toast.info("Feature moved to backlog", { + description: `Moved to Backlog: ${draggedFeature.description.slice( + 0, + 50 + )}${draggedFeature.description.length > 50 ? "..." : ""}`, + }); + } else if ( targetStatus === "verified" && - draggedFeature.status === "in_progress" + draggedFeature.skipTests ) { - // Manual verify via drag + // Manual verify via drag (only for skipTests features) moveFeature(featureId, "verified"); persistFeatureUpdate(featureId, { status: "verified" }); toast.success("Feature verified", { @@ -223,7 +165,10 @@ export function useBoardDragDrop({ 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); - } else if ( + } + } else if (draggedFeature.skipTests) { + // skipTests feature being moved between verified and waiting_approval + if ( targetStatus === "waiting_approval" && draggedFeature.status === "verified" ) { @@ -237,10 +182,9 @@ export function useBoardDragDrop({ )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if (targetStatus === "backlog") { - // Allow moving skipTests cards back to backlog + // Allow moving skipTests cards back to backlog (from verified) moveFeature(featureId, "backlog"); - // Clear worktreePath when moving back to backlog - persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined }); + persistFeatureUpdate(featureId, { status: "backlog" }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -263,8 +207,7 @@ export function useBoardDragDrop({ } else if (targetStatus === "backlog") { // Allow moving verified cards back to backlog moveFeature(featureId, "backlog"); - // Clear worktreePath when moving back to backlog - persistFeatureUpdate(featureId, { status: "backlog", worktreePath: undefined }); + persistFeatureUpdate(featureId, { status: "backlog" }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, @@ -280,9 +223,6 @@ export function useBoardDragDrop({ moveFeature, persistFeatureUpdate, handleStartImplementation, - getOrCreateWorktreeForFeature, - onWorktreeCreated, - useWorktrees, ] ); diff --git a/apps/app/src/components/views/board-view/hooks/use-board-effects.ts b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts index a2784a8d..7aa80c3a 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts @@ -1,7 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { getElectronAPI } from "@/lib/electron"; import { useAppStore } from "@/store/app-store"; -import { useAutoMode } from "@/hooks/use-auto-mode"; interface UseBoardEffectsProps { currentProject: { path: string; id: string } | null; @@ -28,8 +27,6 @@ export function useBoardEffects({ isLoading, setFeaturesWithContext, }: UseBoardEffectsProps) { - const autoMode = useAutoMode(); - // Make current project available globally for modal useEffect(() => { if (currentProject) { @@ -101,8 +98,7 @@ export function useBoardEffects({ const status = await api.autoMode.status(currentProject.path); if (status.success) { const projectId = currentProject.id; - const { clearRunningTasks, addRunningTask, setAutoModeRunning } = - useAppStore.getState(); + const { clearRunningTasks, addRunningTask } = useAppStore.getState(); if (status.runningFeatures) { console.log( @@ -116,14 +112,6 @@ export function useBoardEffects({ addRunningTask(projectId, featureId); }); } - - const isAutoModeRunning = - status.autoLoopRunning ?? status.isRunning ?? false; - console.log( - "[Board] Syncing auto mode running state:", - isAutoModeRunning - ); - setAutoModeRunning(projectId, isAutoModeRunning); } } catch (error) { console.error("[Board] Failed to sync running tasks:", error); diff --git a/apps/app/src/components/views/board-view/kanban-board.tsx b/apps/app/src/components/views/board-view/kanban-board.tsx index 77bd6cc1..94eb0a84 100644 --- a/apps/app/src/components/views/board-view/kanban-board.tsx +++ b/apps/app/src/components/views/board-view/kanban-board.tsx @@ -14,7 +14,7 @@ import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { KanbanColumn, KanbanCard } from "./components"; import { Feature } from "@/store/app-store"; -import { FastForward, Lightbulb, Trash2 } from "lucide-react"; +import { FastForward, Lightbulb, Archive } from "lucide-react"; import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; import { COLUMNS, ColumnId } from "./constants"; @@ -53,7 +53,7 @@ interface KanbanBoardProps { onStartNextFeatures: () => void; onShowSuggestions: () => void; suggestionsCount: number; - onDeleteAllVerified: () => void; + onArchiveAllVerified: () => void; } export function KanbanBoard({ @@ -83,7 +83,7 @@ export function KanbanBoard({ onStartNextFeatures, onShowSuggestions, suggestionsCount, - onDeleteAllVerified, + onArchiveAllVerified, }: KanbanBoardProps) { return (
- - Delete All + + Archive All ) : column.id === "backlog" ? (
diff --git a/apps/app/src/components/views/board-view/shared/branch-selector.tsx b/apps/app/src/components/views/board-view/shared/branch-selector.tsx new file mode 100644 index 00000000..54737887 --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/branch-selector.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { BranchAutocomplete } from "@/components/ui/branch-autocomplete"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; + +interface BranchSelectorProps { + useCurrentBranch: boolean; + onUseCurrentBranchChange: (useCurrent: boolean) => void; + branchName: string; + onBranchNameChange: (branchName: string) => void; + branchSuggestions: string[]; + currentBranch?: string; + disabled?: boolean; + testIdPrefix?: string; +} + +export function BranchSelector({ + useCurrentBranch, + onUseCurrentBranchChange, + branchName, + onBranchNameChange, + branchSuggestions, + currentBranch, + disabled = false, + testIdPrefix = "branch", +}: BranchSelectorProps) { + // Validate: if "other branch" is selected, branch name is required + const isBranchRequired = !useCurrentBranch; + const hasError = isBranchRequired && !branchName.trim(); + + return ( +
+ + onUseCurrentBranchChange(value === "current")} + disabled={disabled} + data-testid={`${testIdPrefix}-radio-group`} + > +
+ + +
+
+ + +
+
+ {!useCurrentBranch && ( +
+ + {hasError && ( +

+ Branch name is required when "Other branch" is selected. +

+ )} +
+ )} + {disabled ? ( +

+ Branch cannot be changed after work has started. +

+ ) : ( +

+ {useCurrentBranch + ? "Work will be done in the currently selected branch. A worktree will be created if needed." + : "Work will be done in this branch. A worktree will be created if needed."} +

+ )} +
+ ); +} + diff --git a/apps/app/src/components/views/board-view/shared/index.ts b/apps/app/src/components/views/board-view/shared/index.ts index 913aa3e5..c5f9bd4f 100644 --- a/apps/app/src/components/views/board-view/shared/index.ts +++ b/apps/app/src/components/views/board-view/shared/index.ts @@ -4,3 +4,4 @@ export * from "./thinking-level-selector"; export * from "./profile-quick-select"; export * from "./testing-tab-content"; export * from "./priority-selector"; +export * from "./branch-selector"; diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index b0cc7870..cf94cfe3 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -170,7 +170,8 @@ export function WorktreeActionsDropdown({ Commit Changes )} - {(worktree.branch !== "main" || worktree.hasChanges) && ( + {/* Show PR option for non-primary worktrees, or primary worktree with changes */} + {(!worktree.isMain || worktree.hasChanges) && ( onCreatePR(worktree)} className="text-xs"> Create Pull Request diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts index 46bc7af1..a0275ebc 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts @@ -1,47 +1,35 @@ "use client"; import { useCallback } from "react"; -import { pathsEqual } from "@/lib/utils"; import type { WorktreeInfo, FeatureInfo } from "../types"; interface UseRunningFeaturesOptions { - projectPath: string; runningFeatureIds: string[]; features: FeatureInfo[]; - getWorktreeKey: (worktree: WorktreeInfo) => string; } export function useRunningFeatures({ - projectPath, runningFeatureIds, features, - getWorktreeKey, }: UseRunningFeaturesOptions) { const hasRunningFeatures = useCallback( (worktree: WorktreeInfo) => { if (runningFeatureIds.length === 0) return false; - const worktreeKey = getWorktreeKey(worktree); - return runningFeatureIds.some((featureId) => { const feature = features.find((f) => f.id === featureId); if (!feature) return false; - if (feature.worktreePath) { - if (worktree.isMain) { - return pathsEqual(feature.worktreePath, projectPath); - } - return pathsEqual(feature.worktreePath, worktreeKey); - } - + // Match by branchName only (worktreePath is no longer stored) if (feature.branchName) { return worktree.branch === feature.branchName; } + // No branch assigned - belongs to main worktree return worktree.isMain; }); }, - [runningFeatureIds, features, projectPath, getWorktreeKey] + [runningFeatureIds, features] ); return { diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts index 39b3ae60..aed28926 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -9,9 +9,10 @@ import type { WorktreeInfo } from "../types"; interface UseWorktreesOptions { projectPath: string; refreshTrigger?: number; + onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; } -export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) { +export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) { const [isLoading, setIsLoading] = useState(false); const [worktrees, setWorktrees] = useState([]); @@ -34,8 +35,11 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp setWorktrees(result.worktrees); setWorktreesInStore(projectPath, result.worktrees); } + // Return removed worktrees so they can be handled by the caller + return result.removedWorktrees; } catch (error) { console.error("Failed to fetch worktrees:", error); + return undefined; } finally { setIsLoading(false); } @@ -47,9 +51,13 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp useEffect(() => { if (refreshTrigger > 0) { - fetchWorktrees(); + fetchWorktrees().then((removedWorktrees) => { + if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) { + onRemovedWorktrees(removedWorktrees); + } + }); } - }, [refreshTrigger, fetchWorktrees]); + }, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]); useEffect(() => { if (worktrees.length > 0) { @@ -59,6 +67,8 @@ export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOp : worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath)); if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) { + // Find the primary worktree and get its branch name + // Fallback to "main" only if worktrees haven't loaded yet const mainWorktree = worktrees.find((w) => w.isMain); const mainBranch = mainWorktree?.branch || "main"; setCurrentWorktree(projectPath, null, mainBranch); diff --git a/apps/app/src/components/views/board-view/worktree-panel/types.ts b/apps/app/src/components/views/board-view/worktree-panel/types.ts index 630aa953..e143ae73 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/app/src/components/views/board-view/worktree-panel/types.ts @@ -22,7 +22,6 @@ export interface DevServerInfo { export interface FeatureInfo { id: string; - worktreePath?: string; branchName?: string; } @@ -33,6 +32,7 @@ export interface WorktreePanelProps { onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void; + onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; runningFeatureIds?: string[]; features?: FeatureInfo[]; refreshTrigger?: number; diff --git a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 53470fd8..ddd27892 100644 --- a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -21,6 +21,7 @@ export function WorktreePanel({ onCommit, onCreatePR, onCreateBranch, + onRemovedWorktrees, runningFeatureIds = [], features = [], refreshTrigger = 0, @@ -33,7 +34,7 @@ export function WorktreePanel({ useWorktreesEnabled, fetchWorktrees, handleSelectWorktree, - } = useWorktrees({ projectPath, refreshTrigger }); + } = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees }); const { isStartingDevServer, @@ -74,10 +75,8 @@ export function WorktreePanel({ const { defaultEditorName } = useDefaultEditor(); const { hasRunningFeatures } = useRunningFeatures({ - projectPath, runningFeatureIds, features, - getWorktreeKey, }); const isWorktreeSelected = (worktree: WorktreeInfo) => { @@ -163,7 +162,12 @@ export function WorktreePanel({ variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground" - onClick={fetchWorktrees} + onClick={async () => { + const removedWorktrees = await fetchWorktrees(); + if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) { + onRemovedWorktrees(removedWorktrees); + } + }} disabled={isLoading} title="Refresh worktrees" > diff --git a/apps/app/src/components/views/context-view.tsx b/apps/app/src/components/views/context-view.tsx index 126c1afe..27d78e35 100644 --- a/apps/app/src/components/views/context-view.tsx +++ b/apps/app/src/components/views/context-view.tsx @@ -213,15 +213,25 @@ export function ContextView() { await api.writeFile(filePath, newFileContent); } + // Close dialog and reset state immediately after successful file write setIsAddDialogOpen(false); setNewFileName(""); setNewFileType("text"); setUploadedImageData(null); setNewFileContent(""); setIsDropHovering(false); + + // Load files after dialog is closed await loadContextFiles(); } catch (error) { console.error("Failed to add file:", error); + // Still close the dialog even if loadContextFiles fails + setIsAddDialogOpen(false); + setNewFileName(""); + setNewFileType("text"); + setUploadedImageData(null); + setNewFileContent(""); + setIsDropHovering(false); } }; diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts index 4fac3f41..f1690a48 100644 --- a/apps/app/src/hooks/use-auto-mode.ts +++ b/apps/app/src/hooks/use-auto-mode.ts @@ -13,7 +13,6 @@ export function useAutoMode() { setAutoModeRunning, addRunningTask, removeRunningTask, - clearRunningTasks, currentProject, addAutoModeActivity, maxConcurrency, @@ -24,7 +23,6 @@ export function useAutoMode() { setAutoModeRunning: state.setAutoModeRunning, addRunningTask: state.addRunningTask, removeRunningTask: state.removeRunningTask, - clearRunningTasks: state.clearRunningTasks, currentProject: state.currentProject, addAutoModeActivity: state.addAutoModeActivity, maxConcurrency: state.maxConcurrency, @@ -119,33 +117,6 @@ export function useAutoMode() { } break; - case "auto_mode_stopped": - // Auto mode was explicitly stopped (by user or error) - setAutoModeRunning(eventProjectId, false); - clearRunningTasks(eventProjectId); - console.log("[AutoMode] Auto mode stopped"); - break; - - case "auto_mode_started": - // Auto mode started - ensure UI reflects running state - console.log("[AutoMode] Auto mode started:", event.message); - break; - - case "auto_mode_idle": - // Auto mode is running but has no pending features to pick up - // This is NOT a stop - auto mode keeps running and will pick up new features - console.log("[AutoMode] Auto mode idle - waiting for new features"); - break; - - case "auto_mode_complete": - // Legacy event - only handle if it looks like a stop (for backwards compatibility) - if (event.message === "Auto mode stopped") { - setAutoModeRunning(eventProjectId, false); - clearRunningTasks(eventProjectId); - console.log("[AutoMode] Auto mode stopped (legacy event)"); - } - break; - case "auto_mode_error": console.error("[AutoMode Error]", event.error); if (event.featureId && event.error) { @@ -218,128 +189,35 @@ export function useAutoMode() { projectId, addRunningTask, removeRunningTask, - clearRunningTasks, setAutoModeRunning, addAutoModeActivity, getProjectIdFromPath, ]); - // Restore auto mode for all projects that were running when app was closed - // This runs once on mount to restart auto loops for persisted running states - useEffect(() => { - const api = getElectronAPI(); - if (!api?.autoMode) return; - - // Find all projects that have auto mode marked as running - const projectsToRestart: Array<{ projectId: string; projectPath: string }> = - []; - for (const [projectId, state] of Object.entries(autoModeByProject)) { - if (state.isRunning) { - // Find the project path for this project ID - const project = projects.find((p) => p.id === projectId); - if (project) { - projectsToRestart.push({ projectId, projectPath: project.path }); - } - } - } - - // Restart auto mode for each project - for (const { projectId, projectPath } of projectsToRestart) { - console.log(`[AutoMode] Restoring auto mode for project: ${projectPath}`); - api.autoMode - .start(projectPath, maxConcurrency) - .then((result) => { - if (!result.success) { - console.error( - `[AutoMode] Failed to restore auto mode for ${projectPath}:`, - result.error - ); - // Mark as not running if we couldn't restart - setAutoModeRunning(projectId, false); - } else { - console.log(`[AutoMode] Restored auto mode for ${projectPath}`); - } - }) - .catch((error) => { - console.error( - `[AutoMode] Error restoring auto mode for ${projectPath}:`, - error - ); - setAutoModeRunning(projectId, false); - }); - } - // Only run once on mount - intentionally empty dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Start auto mode - const start = useCallback(async () => { + // Start auto mode - UI only, feature pickup is handled in board-view.tsx + const start = useCallback(() => { if (!currentProject) { console.error("No project selected"); return; } - try { - const api = getElectronAPI(); - if (!api?.autoMode) { - throw new Error("Auto mode API not available"); - } - - const result = await api.autoMode.start( - currentProject.path, - maxConcurrency - ); - - if (result.success) { - setAutoModeRunning(currentProject.id, true); - console.log( - `[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}` - ); - } else { - console.error("[AutoMode] Failed to start:", result.error); - throw new Error(result.error || "Failed to start auto mode"); - } - } catch (error) { - console.error("[AutoMode] Error starting:", error); - if (currentProject) { - setAutoModeRunning(currentProject.id, false); - } - throw error; - } + setAutoModeRunning(currentProject.id, true); + console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`); }, [currentProject, setAutoModeRunning, maxConcurrency]); - // Stop auto mode - only turns off the toggle, running tasks continue - const stop = useCallback(async () => { + // Stop auto mode - UI only, running tasks continue until natural completion + const stop = useCallback(() => { if (!currentProject) { console.error("No project selected"); return; } - try { - const api = getElectronAPI(); - if (!api?.autoMode) { - throw new Error("Auto mode API not available"); - } - - const result = await api.autoMode.stop(currentProject.path); - - if (result.success) { - setAutoModeRunning(currentProject.id, false); - // NOTE: We intentionally do NOT clear running tasks here. - // Stopping auto mode only turns off the toggle to prevent new features - // from being picked up. Running tasks will complete naturally and be - // removed via the auto_mode_feature_complete event. - console.log( - "[AutoMode] Stopped successfully - running tasks will continue" - ); - } else { - console.error("[AutoMode] Failed to stop:", result.error); - throw new Error(result.error || "Failed to stop auto mode"); - } - } catch (error) { - console.error("[AutoMode] Error stopping:", error); - throw error; - } + setAutoModeRunning(currentProject.id, false); + // NOTE: We intentionally do NOT clear running tasks here. + // Stopping auto mode only turns off the toggle to prevent new features + // from being picked up. Running tasks will complete naturally and be + // removed via the auto_mode_feature_complete event. + console.log("[AutoMode] Stopped - running tasks will continue"); }, [currentProject, setAutoModeRunning]); // Stop a specific feature diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts index 5fff0d55..a73574a3 100644 --- a/apps/app/src/lib/electron.ts +++ b/apps/app/src/lib/electron.ts @@ -80,7 +80,6 @@ export interface RunningAgentsResult { success: boolean; runningAgents?: RunningAgent[]; totalCount?: number; - autoLoopRunning?: boolean; error?: string; } @@ -217,7 +216,6 @@ export interface AutoModeAPI { status: (projectPath?: string) => Promise<{ success: boolean; isRunning?: boolean; - autoLoopRunning?: boolean; // Backend uses this name instead of isRunning currentFeatureId?: string | null; runningFeatures?: string[]; runningProjects?: string[]; @@ -1442,7 +1440,6 @@ function createMockAutoModeAPI(): AutoModeAPI { return { success: true, isRunning: mockAutoModeRunning, - autoLoopRunning: mockAutoModeRunning, currentFeatureId: mockAutoModeRunning ? "feature-0" : null, runningFeatures: Array.from(mockRunningFeatures), runningCount: mockRunningFeatures.size, @@ -2593,7 +2590,6 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI { success: true, runningAgents, totalCount: runningAgents.length, - autoLoopRunning: mockAutoModeRunning, }; }, }; diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index d007b806..3fe6fdd2 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -738,7 +738,6 @@ export class HttpApiClient implements ElectronAPI { isAutoMode: boolean; }>; totalCount?: number; - autoLoopRunning?: boolean; error?: string; }> => this.get("/api/running-agents"), }; diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 41033199..2c7d2b5d 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -296,9 +296,8 @@ export interface Feature { error?: string; // Error message if the agent errored during processing priority?: number; // Priority: 1 = high, 2 = medium, 3 = low dependencies?: string[]; // Array of feature IDs this feature depends on - // Worktree info - set when a feature is being worked on in an isolated git worktree - worktreePath?: string; // Path to the worktree directory - branchName?: string; // Name of the feature branch + // Branch info - worktree path is derived at runtime from branchName + branchName?: string; // Name of the feature branch (undefined = use current worktree) justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes) } @@ -404,7 +403,10 @@ export interface AppState { // User-managed Worktrees (per-project) // projectPath -> { path: worktreePath or null for main, branch: branch name } - currentWorktreeByProject: Record; + currentWorktreeByProject: Record< + string, + { path: string | null; branch: string } + >; worktreesByProject: Record< string, Array<{ @@ -588,7 +590,11 @@ export interface AppActions { // Worktree Settings actions setUseWorktrees: (enabled: boolean) => void; - setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void; + setCurrentWorktree: ( + projectPath: string, + worktreePath: string | null, + branch: string + ) => void; setWorktrees: ( projectPath: string, worktrees: Array<{ @@ -599,7 +605,9 @@ export interface AppActions { changedFilesCount?: number; }> ) => void; - getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null; + getCurrentWorktree: ( + projectPath: string + ) => { path: string | null; branch: string } | null; getWorktrees: (projectPath: string) => Array<{ path: string; branch: string; @@ -607,6 +615,8 @@ export interface AppActions { hasChanges?: boolean; changedFilesCount?: number; }>; + isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean; + getPrimaryWorktreeBranch: (projectPath: string) => string | null; // Profile Display Settings actions setShowProfilesOnly: (enabled: boolean) => void; @@ -1347,7 +1357,8 @@ export const useAppStore = create()( // Feature Default Settings actions setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), - setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), + setEnableDependencyBlocking: (enabled) => + set({ enableDependencyBlocking: enabled }), // Worktree Settings actions setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), @@ -1380,6 +1391,18 @@ export const useAppStore = create()( return get().worktreesByProject[projectPath] ?? []; }, + isPrimaryWorktreeBranch: (projectPath, branchName) => { + const worktrees = get().worktreesByProject[projectPath] ?? []; + const primary = worktrees.find((w) => w.isMain); + return primary?.branch === branchName; + }, + + getPrimaryWorktreeBranch: (projectPath) => { + const worktrees = get().worktreesByProject[projectPath] ?? []; + const primary = worktrees.find((w) => w.isMain); + return primary?.branch ?? null; + }, + // Profile Display Settings actions setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }), @@ -2237,7 +2260,8 @@ export const useAppStore = create()( // Settings apiKeys: state.apiKeys, maxConcurrency: state.maxConcurrency, - autoModeByProject: state.autoModeByProject, + // Note: autoModeByProject is intentionally NOT persisted + // Auto-mode should always default to OFF on app refresh defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, useWorktrees: state.useWorktrees, diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index 4a0f973b..aa938ac6 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -198,30 +198,6 @@ export type AutoModeEvent = projectId?: string; projectPath?: string; } - | { - type: "auto_mode_complete"; - message: string; - projectId?: string; - projectPath?: string; - } - | { - type: "auto_mode_stopped"; - message: string; - projectId?: string; - projectPath?: string; - } - | { - type: "auto_mode_started"; - message: string; - projectId?: string; - projectPath?: string; - } - | { - type: "auto_mode_idle"; - message: string; - projectId?: string; - projectPath?: string; - } | { type: "auto_mode_phase"; featureId: string; @@ -310,20 +286,6 @@ export interface SpecRegenerationAPI { } export interface AutoModeAPI { - start: ( - projectPath: string, - maxConcurrency?: number - ) => Promise<{ - success: boolean; - error?: string; - }>; - - stop: (projectPath: string) => Promise<{ - success: boolean; - error?: string; - runningFeatures?: number; - }>; - stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string; @@ -331,7 +293,6 @@ export interface AutoModeAPI { status: (projectPath?: string) => Promise<{ success: boolean; - autoLoopRunning?: boolean; isRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; @@ -343,8 +304,7 @@ export interface AutoModeAPI { runFeature: ( projectPath: string, featureId: string, - useWorktrees?: boolean, - worktreePath?: string + useWorktrees?: boolean ) => Promise<{ success: boolean; passes?: boolean; @@ -390,7 +350,7 @@ export interface AutoModeAPI { featureId: string, prompt: string, imagePaths?: string[], - worktreePath?: string + useWorktrees?: boolean ) => Promise<{ success: boolean; passes?: boolean; @@ -632,6 +592,10 @@ export interface WorktreeAPI { hasChanges?: boolean; changedFilesCount?: number; }>; + removedWorktrees?: Array<{ + path: string; + branch: string; + }>; error?: string; }>; diff --git a/apps/app/tests/utils/core/constants.ts b/apps/app/tests/utils/core/constants.ts index 935436c0..922e4bc6 100644 --- a/apps/app/tests/utils/core/constants.ts +++ b/apps/app/tests/utils/core/constants.ts @@ -97,7 +97,7 @@ export const TEST_IDS = { addFeatureButton: "add-feature-button", addFeatureDialog: "add-feature-dialog", confirmAddFeature: "confirm-add-feature", - featureBranchInput: "feature-branch-input", + featureBranchInput: "feature-input", featureCategoryInput: "feature-category-input", worktreeSelector: "worktree-selector", diff --git a/apps/app/tests/utils/views/board.ts b/apps/app/tests/utils/views/board.ts index 6295c77b..c1407357 100644 --- a/apps/app/tests/utils/views/board.ts +++ b/apps/app/tests/utils/views/board.ts @@ -137,8 +137,17 @@ export async function fillAddFeatureDialog( // Fill branch if provided (it's a combobox autocomplete) if (options?.branch) { - const branchButton = page.locator('[data-testid="feature-branch-input"]'); - await branchButton.click(); + // First, select "Other branch" radio option if not already selected + const otherBranchRadio = page.locator('[data-testid="feature-radio-group"]').locator('[id="feature-other"]'); + await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 }); + await otherBranchRadio.click(); + // Wait for the branch input to appear + await page.waitForTimeout(300); + + // Now click on the branch input (autocomplete) + const branchInput = page.locator('[data-testid="feature-input"]'); + await branchInput.waitFor({ state: "visible", timeout: 5000 }); + await branchInput.click(); // Wait for the popover to open await page.waitForTimeout(300); // Type in the command input diff --git a/apps/app/tests/worktree-integration.spec.ts b/apps/app/tests/worktree-integration.spec.ts index f1eccbb9..c21228c4 100644 --- a/apps/app/tests/worktree-integration.spec.ts +++ b/apps/app/tests/worktree-integration.spec.ts @@ -741,14 +741,15 @@ test.describe("Worktree Integration Tests", () => { await waitForNetworkIdle(page); await waitForBoardView(page); - // Create a worktree first + // Note: Worktrees are created at execution time (when feature starts), + // not when adding to backlog. We can specify a branch name without + // creating a worktree first. const branchName = "feature/test-branch"; - await apiCreateWorktree(page, testRepo.path, branchName); // Click add feature button await clickAddFeature(page); - // Fill in the feature details + // Fill in the feature details with a branch name await fillAddFeatureDialog(page, "Test feature for worktree", { branch: branchName, category: "Testing", @@ -773,9 +774,12 @@ test.describe("Worktree Integration Tests", () => { expect(featureData.description).toBe("Test feature for worktree"); expect(featureData.branchName).toBe(branchName); expect(featureData.status).toBe("backlog"); + // Verify worktreePath is not set when adding to backlog + // (worktrees are created at execution time, not when adding to backlog) + expect(featureData.worktreePath).toBeUndefined(); }); - test("should create worktree automatically when adding feature with new branch", async ({ + test("should store branch name when adding feature with new branch (worktree created at execution)", async ({ page, }) => { await setupProjectWithPath(page, testRepo.path); @@ -783,12 +787,13 @@ test.describe("Worktree Integration Tests", () => { await waitForNetworkIdle(page); await waitForBoardView(page); - // Use a branch name that doesn't exist yet - NO worktree is pre-created + // Use a branch name that doesn't exist yet + // Note: Worktrees are now created at execution time, not when adding to backlog const branchName = "feature/auto-create-worktree"; - const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); - // Verify worktree does NOT exist before we create the feature - expect(fs.existsSync(expectedWorktreePath)).toBe(false); + // Verify branch does NOT exist before we create the feature + const branchesBefore = await listBranches(testRepo.path); + expect(branchesBefore).not.toContain(branchName); // Click add feature button await clickAddFeature(page); @@ -802,17 +807,14 @@ test.describe("Worktree Integration Tests", () => { // Confirm await confirmAddFeature(page); - // Wait for the worktree to be created - await page.waitForTimeout(2000); + // Wait for feature to be saved + await page.waitForTimeout(1000); - // Verify worktree was automatically created when feature was added - expect(fs.existsSync(expectedWorktreePath)).toBe(true); + // Verify branch was NOT created when adding feature (created at execution time) + const branchesAfter = await listBranches(testRepo.path); + expect(branchesAfter).not.toContain(branchName); - // Verify the branch was created - const branches = await listBranches(testRepo.path); - expect(branches).toContain(branchName); - - // Verify feature was created with correct branch + // Verify feature was created with correct branch name stored const featuresDir = path.join(testRepo.path, ".automaker", "features"); const featureDirs = fs.readdirSync(featuresDir); expect(featureDirs.length).toBeGreaterThan(0); @@ -829,8 +831,15 @@ test.describe("Worktree Integration Tests", () => { const featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + + // Verify branch name is stored expect(featureData.branchName).toBe(branchName); - expect(featureData.worktreePath).toBe(expectedWorktreePath); + + // Verify worktreePath is NOT set (worktrees are created at execution time) + expect(featureData.worktreePath).toBeUndefined(); + + // Verify feature is in backlog status + expect(featureData.status).toBe("backlog"); }); test("should reset feature branch and worktree when worktree is deleted", async ({ @@ -887,8 +896,11 @@ test.describe("Worktree Integration Tests", () => { let featureFilePath = path.join(featuresDir, featureDir!, "feature.json"); let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); + + // Verify feature was created with the branch name stored expect(featureData.branchName).toBe(branchName); - expect(featureData.worktreePath).toBe(worktreePath); + // Verify worktreePath is NOT set (worktrees are created at execution time, not when adding) + expect(featureData.worktreePath).toBeUndefined(); // Delete the worktree via UI // Open the worktree actions menu @@ -911,10 +923,11 @@ test.describe("Worktree Integration Tests", () => { // Verify worktree is deleted expect(fs.existsSync(worktreePath)).toBe(false); - // Verify feature's branchName and worktreePath are reset to null + // Verify feature's branchName is reset to null/undefined when worktree is deleted + // (worktreePath was never stored, so it remains undefined) featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8")); expect(featureData.branchName).toBeNull(); - expect(featureData.worktreePath).toBeNull(); + expect(featureData.worktreePath).toBeUndefined(); // Verify the feature appears in the backlog when main is selected const mainButton = page.getByRole("button", { name: "main" }).first(); @@ -940,8 +953,9 @@ test.describe("Worktree Integration Tests", () => { await otherWorktreeButton.click(); await page.waitForTimeout(500); - // Unassigned features should still be visible in the backlog - await expect(featureText).toBeVisible({ timeout: 5000 }); + // Unassigned features should NOT be visible on non-primary worktrees + // They should only show on the primary (main) worktree + await expect(featureText).not.toBeVisible({ timeout: 5000 }); }); test("should filter features by selected worktree", async ({ page }) => { @@ -1062,9 +1076,11 @@ test.describe("Worktree Integration Tests", () => { // Open add feature dialog await clickAddFeature(page); - // Verify the branch input button shows the selected worktree's branch - const branchButton = page.locator('[data-testid="feature-branch-input"]'); - await expect(branchButton).toContainText(branchName, { timeout: 5000 }); + // Verify the branch selector shows the selected worktree's branch + // When a worktree is selected, "Use current selected branch" should be selected + // and the branch name should be shown in the label + const currentBranchLabel = page.locator('label[for="feature-current"]'); + await expect(currentBranchLabel).toContainText(branchName, { timeout: 5000 }); // Close dialog await page.keyboard.press("Escape"); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 93253c47..eab3e9c1 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -6,8 +6,6 @@ import { Router } from "express"; import type { AutoModeService } from "../../services/auto-mode-service.js"; -import { createStartHandler } from "./routes/start.js"; -import { createStopHandler } from "./routes/stop.js"; import { createStopFeatureHandler } from "./routes/stop-feature.js"; import { createStatusHandler } from "./routes/status.js"; import { createRunFeatureHandler } from "./routes/run-feature.js"; @@ -21,8 +19,6 @@ import { createCommitFeatureHandler } from "./routes/commit-feature.js"; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); - router.post("/start", createStartHandler(autoModeService)); - router.post("/stop", createStopHandler(autoModeService)); router.post("/stop-feature", createStopFeatureHandler(autoModeService)); router.post("/status", createStatusHandler(autoModeService)); router.post("/run-feature", createRunFeatureHandler(autoModeService)); diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index aa8887ad..1b470a25 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -12,13 +12,14 @@ const logger = createLogger("AutoMode"); export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as { - projectPath: string; - featureId: string; - prompt: string; - imagePaths?: string[]; - worktreePath?: string; - }; + const { projectPath, featureId, prompt, imagePaths, useWorktrees } = + req.body as { + projectPath: string; + featureId: string; + prompt: string; + imagePaths?: string[]; + useWorktrees?: boolean; + }; if (!projectPath || !featureId || !prompt) { res.status(400).json({ @@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return; } - // Start follow-up in background, using the feature's worktreePath for correct branch + // Start follow-up in background + // followUpFeature derives workDir from feature.branchName autoModeService - .followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath) + .followUpFeature( + projectPath, + featureId, + prompt, + imagePaths, + useWorktrees ?? true + ) .catch((error) => { logger.error( `[AutoMode] Follow up feature ${featureId} error:`, error ); + }) + .finally(() => { + // Release the starting slot when follow-up completes (success or error) + // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts index 94f5b056..45410ba7 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: "projectPath and featureId are required", + }); return; } @@ -34,7 +32,8 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) { .resumeFeature(projectPath, featureId, useWorktrees ?? false) .catch((error) => { logger.error(`[AutoMode] Resume feature ${featureId} error:`, error); - }); + }) + .finally(() => {}); res.json({ success: true }); } catch (error) { diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index da2f1f6c..bae005f3 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -12,30 +12,30 @@ const logger = createLogger("AutoMode"); export function createRunFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, useWorktrees, worktreePath } = req.body as { + const { projectPath, featureId, useWorktrees } = req.body as { projectPath: string; featureId: string; useWorktrees?: boolean; - worktreePath?: string; }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: "projectPath and featureId are required", + }); return; } // Start execution in background - // If worktreePath is provided, use it directly; otherwise let the service decide - // Default to false - worktrees should only be used when explicitly enabled + // executeFeature derives workDir from feature.branchName autoModeService - .executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath) + .executeFeature(projectPath, featureId, useWorktrees ?? false, false) .catch((error) => { logger.error(`[AutoMode] Feature ${featureId} error:`, error); + }) + .finally(() => { + // Release the starting slot when execution completes (success or error) + // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts deleted file mode 100644 index 9868cd1a..00000000 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * POST /start endpoint - Start auto mode loop - */ - -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; - -export function createStartHandler(autoModeService: AutoModeService) { - return async (req: Request, res: Response): Promise => { - try { - const { projectPath, maxConcurrency } = req.body as { - projectPath: string; - maxConcurrency?: number; - }; - - if (!projectPath) { - res - .status(400) - .json({ success: false, error: "projectPath is required" }); - return; - } - - await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3); - res.json({ success: true }); - } catch (error) { - logError(error, "Start auto loop failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); - } - }; -} diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts deleted file mode 100644 index 69f21fc3..00000000 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * POST /stop endpoint - Stop auto mode loop - */ - -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; - -export function createStopHandler(autoModeService: AutoModeService) { - return async (req: Request, res: Response): Promise => { - try { - const runningCount = await autoModeService.stopAutoLoop(); - res.json({ success: true, runningFeatures: runningCount }); - } catch (error) { - logError(error, "Stop auto loop failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); - } - }; -} diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 8d1f8760..e2f7e14e 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) { success: true, runningAgents, totalCount: runningAgents.length, - autoLoopRunning: status.autoLoopRunning, }); } catch (error) { logError(error, "Get running agents failed"); diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 1a3477f0..ef749e9c 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -8,6 +8,7 @@ import type { Request, Response } from "express"; import { exec } from "child_process"; import { promisify } from "util"; +import { existsSync } from "fs"; import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; const execAsync = promisify(exec); @@ -58,10 +59,12 @@ export function createListHandler() { }); const worktrees: WorktreeInfo[] = []; + const removedWorktrees: Array<{ path: string; branch: string }> = []; const lines = stdout.split("\n"); let current: { path?: string; branch?: string } = {}; let isFirst = true; + // First pass: detect removed worktrees for (const line of lines) { if (line.startsWith("worktree ")) { current.path = normalizePath(line.slice(9)); @@ -69,19 +72,40 @@ export function createListHandler() { current.branch = line.slice(7).replace("refs/heads/", ""); } else if (line === "") { if (current.path && current.branch) { - worktrees.push({ - path: current.path, - branch: current.branch, - isMain: isFirst, - isCurrent: current.branch === currentBranch, - hasWorktree: true, - }); - isFirst = false; + const isMainWorktree = isFirst; + // Check if the worktree directory actually exists + // Skip checking/pruning the main worktree (projectPath itself) + if (!isMainWorktree && !existsSync(current.path)) { + // Worktree directory doesn't exist - it was manually deleted + removedWorktrees.push({ + path: current.path, + branch: current.branch, + }); + } else { + // Worktree exists (or is main worktree), add it to the list + worktrees.push({ + path: current.path, + branch: current.branch, + isMain: isMainWorktree, + isCurrent: current.branch === currentBranch, + hasWorktree: true, + }); + isFirst = false; + } } current = {}; } } + // Prune removed worktrees from git (only if any were detected) + if (removedWorktrees.length > 0) { + try { + await execAsync("git worktree prune", { cwd: projectPath }); + } catch { + // Prune failed, but we'll still report the removed worktrees + } + } + // If includeDetails is requested, fetch change status for each worktree if (includeDetails) { for (const worktree of worktrees) { @@ -103,7 +127,11 @@ export function createListHandler() { } } - res.json({ success: true, worktrees }); + res.json({ + success: true, + worktrees, + removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, + }); } catch (error) { logError(error, "List worktrees failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 5c9f6785..12831a3d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,14 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js"; import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; -import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js"; import type { Feature } from "./feature-loader.js"; -import { - getFeatureDir, - getFeaturesDir, - getAutomakerDir, - getWorktreesDir, -} from "../lib/automaker-paths.js"; +import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js"; const execAsync = promisify(exec); @@ -41,196 +35,43 @@ interface RunningFeature { startTime: number; } -interface AutoModeConfig { - maxConcurrency: number; - useWorktrees: boolean; - projectPath: string; -} - export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); - private autoLoopRunning = false; - private autoLoopAbortController: AbortController | null = null; - private config: AutoModeConfig | null = null; constructor(events: EventEmitter) { this.events = events; } - /** - * Start the auto mode loop - continuously picks and executes pending features - */ - async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { - if (this.autoLoopRunning) { - throw new Error("Auto mode is already running"); - } - - this.autoLoopRunning = true; - this.autoLoopAbortController = new AbortController(); - this.config = { - maxConcurrency, - useWorktrees: true, - projectPath, - }; - - this.emitAutoModeEvent("auto_mode_started", { - message: `Auto mode started with max ${maxConcurrency} concurrent features`, - projectPath, - }); - - // Run the loop in the background - this.runAutoLoop().catch((error) => { - console.error("[AutoMode] Loop error:", error); - this.emitAutoModeEvent("auto_mode_error", { - error: error.message, - }); - }); - } - - private async runAutoLoop(): Promise { - while ( - this.autoLoopRunning && - this.autoLoopAbortController && - !this.autoLoopAbortController.signal.aborted - ) { - try { - // Check if we have capacity - if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { - await this.sleep(5000); - continue; - } - - // Load pending features - const pendingFeatures = await this.loadPendingFeatures( - this.config!.projectPath - ); - - if (pendingFeatures.length === 0) { - this.emitAutoModeEvent("auto_mode_idle", { - message: "No pending features - auto mode idle", - projectPath: this.config!.projectPath, - }); - await this.sleep(10000); - continue; - } - - // Find a feature not currently running - const nextFeature = pendingFeatures.find( - (f) => !this.runningFeatures.has(f.id) - ); - - if (nextFeature) { - // Start feature execution in background - this.executeFeature( - this.config!.projectPath, - nextFeature.id, - this.config!.useWorktrees, - true - ).catch((error) => { - console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error); - }); - } - - await this.sleep(2000); - } catch (error) { - console.error("[AutoMode] Loop iteration error:", error); - await this.sleep(5000); - } - } - - this.autoLoopRunning = false; - } - - /** - * Stop the auto mode loop - */ - async stopAutoLoop(): Promise { - const wasRunning = this.autoLoopRunning; - this.autoLoopRunning = false; - if (this.autoLoopAbortController) { - this.autoLoopAbortController.abort(); - this.autoLoopAbortController = null; - } - - // Emit stop event immediately when user explicitly stops - if (wasRunning) { - this.emitAutoModeEvent("auto_mode_stopped", { - message: "Auto mode stopped", - projectPath: this.config?.projectPath, - }); - } - - return this.runningFeatures.size; - } - /** * Execute a single feature * @param projectPath - The main project path * @param featureId - The feature ID to execute * @param useWorktrees - Whether to use worktrees for isolation * @param isAutoMode - Whether this is running in auto mode - * @param providedWorktreePath - Optional: use this worktree path instead of creating a new one */ async executeFeature( projectPath: string, featureId: string, useWorktrees = false, - isAutoMode = false, - providedWorktreePath?: string + isAutoMode = false ): Promise { if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); } - const abortController = new AbortController(); - const branchName = `feature/${featureId}`; - let worktreePath: string | null = null; - - // Use provided worktree path if given, otherwise setup new worktree if enabled - if (providedWorktreePath) { - // Resolve to absolute path - critical for cross-platform compatibility - // On Windows, relative paths or paths with forward slashes may not work correctly with cwd - // On all platforms, absolute paths ensure commands execute in the correct directory - try { - // Resolve relative paths relative to projectPath, absolute paths as-is - const resolvedPath = path.isAbsolute(providedWorktreePath) - ? path.resolve(providedWorktreePath) - : path.resolve(projectPath, providedWorktreePath); - - // Verify the path exists before using it - await fs.access(resolvedPath); - worktreePath = resolvedPath; - console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`); - } catch (error) { - console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error); - // Fall through to create new worktree or use project path - } - } - - if (!worktreePath && useWorktrees) { - // No specific worktree provided, create a new one for this feature - worktreePath = await this.setupWorktree( - projectPath, - featureId, - branchName + // Check if feature has existing context - if so, resume instead of starting fresh + const hasExistingContext = await this.contextExists(projectPath, featureId); + if (hasExistingContext) { + console.log( + `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` ); + return this.resumeFeature(projectPath, featureId, useWorktrees); } - // Ensure workDir is always an absolute path for cross-platform compatibility - const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + const abortController = new AbortController(); - this.runningFeatures.set(featureId, { - featureId, - projectPath, - worktreePath, - branchName, - abortController, - isAutoMode, - startTime: Date.now(), - }); - - // Emit feature start event + // Emit feature start event early this.emitAutoModeEvent("auto_mode_feature_start", { featureId, projectPath, @@ -242,12 +83,53 @@ export class AutoModeService { }); try { - // Load feature details + // Load feature details FIRST to get branchName const feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } + // Derive workDir from feature.branchName + // If no branchName, use the project path directly + let worktreePath: string | null = null; + const branchName = feature.branchName || null; + + if (useWorktrees && branchName) { + // Try to find existing worktree for this branch + worktreePath = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); + + if (!worktreePath) { + // Create worktree for this branch + worktreePath = await this.setupWorktree( + projectPath, + featureId, + branchName + ); + } + + console.log( + `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` + ); + } + + // Ensure workDir is always an absolute path for cross-platform compatibility + const workDir = worktreePath + ? path.resolve(worktreePath) + : path.resolve(projectPath); + + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath, + branchName, + abortController, + isAutoMode, + startTime: Date.now(), + }); + // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -262,7 +144,7 @@ export class AutoModeService { // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); console.log( - `[AutoMode] Executing feature ${featureId} with model: ${model}` + `[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}` ); // Run the agent with the feature's model and images @@ -271,6 +153,7 @@ export class AutoModeService { featureId, prompt, abortController, + projectPath, imagePaths, model ); @@ -371,7 +254,7 @@ export class AutoModeService { featureId: string, prompt: string, imagePaths?: string[], - providedWorktreePath?: string + useWorktrees = true ): Promise { if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); @@ -379,32 +262,29 @@ export class AutoModeService { const abortController = new AbortController(); - // Use the provided worktreePath (from the feature's assigned branch) - // Fall back to project path if not provided + // Load feature info for context FIRST to get branchName + const feature = await this.loadFeature(projectPath, featureId); + + // Derive workDir from feature.branchName let workDir = path.resolve(projectPath); let worktreePath: string | null = null; + const branchName = feature?.branchName || null; - if (providedWorktreePath) { - try { - // Resolve to absolute path - critical for cross-platform compatibility - // On Windows, relative paths or paths with forward slashes may not work correctly with cwd - // On all platforms, absolute paths ensure commands execute in the correct directory - const resolvedPath = path.isAbsolute(providedWorktreePath) - ? path.resolve(providedWorktreePath) - : path.resolve(projectPath, providedWorktreePath); - - await fs.access(resolvedPath); - workDir = resolvedPath; - worktreePath = resolvedPath; - } catch { - // Worktree path provided but doesn't exist, use project path - console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`); + if (useWorktrees && branchName) { + // Try to find existing worktree for this branch + worktreePath = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); + + if (worktreePath) { + workDir = worktreePath; + console.log( + `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}` + ); } } - // Load feature info for context - const feature = await this.loadFeature(projectPath, featureId); - // Load previous agent output if it exists const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); @@ -441,7 +321,7 @@ Address the follow-up instructions above. Review the previous work and make the featureId, projectPath, worktreePath, - branchName: worktreePath ? path.basename(worktreePath) : null, + branchName, abortController, isAutoMode: false, startTime: Date.now(), @@ -537,6 +417,7 @@ Address the follow-up instructions above. Review the previous work and make the featureId, fullPrompt, abortController, + projectPath, allImagePaths.length > 0 ? allImagePaths : imagePaths, model, previousContext || undefined @@ -653,17 +534,25 @@ Address the follow-up instructions above. Review the previous work and make the workDir = providedWorktreePath; console.log(`[AutoMode] Committing in provided worktree: ${workDir}`); } catch { - console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`); + console.log( + `[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` + ); } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId); + const legacyWorktreePath = path.join( + projectPath, + ".worktrees", + featureId + ); try { await fs.access(legacyWorktreePath); workDir = legacyWorktreePath; console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); } catch { - console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); + console.log( + `[AutoMode] No worktree found, committing in project path: ${workDir}` + ); } } @@ -816,13 +705,11 @@ Format your response as a structured markdown document.`; */ getStatus(): { isRunning: boolean; - autoLoopRunning: boolean; runningFeatures: string[]; runningCount: number; } { return { - isRunning: this.autoLoopRunning || this.runningFeatures.size > 0, - autoLoopRunning: this.autoLoopRunning, + isRunning: this.runningFeatures.size > 0, runningFeatures: Array.from(this.runningFeatures.keys()), runningCount: this.runningFeatures.size, }; @@ -905,10 +792,15 @@ Format your response as a structured markdown document.`; branchName: string ): Promise { // First, check if git already has a worktree for this branch (anywhere) - const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName); + const existingWorktree = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); if (existingWorktree) { // Path is already resolved to absolute in findExistingWorktreeForBranch - console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`); + console.log( + `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}` + ); return existingWorktree; } @@ -992,56 +884,6 @@ Format your response as a structured markdown document.`; } } - private async loadPendingFeatures(projectPath: string): Promise { - // Features are stored in .automaker directory - const featuresDir = getFeaturesDir(projectPath); - - try { - const entries = await fs.readdir(featuresDir, { withFileTypes: true }); - const allFeatures: Feature[] = []; - const pendingFeatures: Feature[] = []; - - // Load all features (for dependency checking) - for (const entry of entries) { - if (entry.isDirectory()) { - const featurePath = path.join( - featuresDir, - entry.name, - "feature.json" - ); - try { - const data = await fs.readFile(featurePath, "utf-8"); - const feature = JSON.parse(data); - allFeatures.push(feature); - - // Track pending features separately - if ( - feature.status === "pending" || - feature.status === "ready" || - feature.status === "backlog" - ) { - pendingFeatures.push(feature); - } - } catch { - // Skip invalid features - } - } - } - - // Apply dependency-aware ordering - const { orderedFeatures } = resolveDependencies(pendingFeatures); - - // Filter to only features with satisfied dependencies - const readyFeatures = orderedFeatures.filter(feature => - areDependenciesSatisfied(feature, allFeatures) - ); - - return readyFeatures; - } catch { - return []; - } - } - /** * Extract a title from feature description (first line or truncated) */ @@ -1060,31 +902,6 @@ Format your response as a structured markdown document.`; return firstLine.substring(0, 57) + "..."; } - /** - * Extract image paths from feature's imagePaths array - * Handles both string paths and objects with path property - */ - private extractImagePaths( - imagePaths: - | Array - | undefined, - projectPath: string - ): string[] { - if (!imagePaths || imagePaths.length === 0) { - return []; - } - - return imagePaths - .map((imgPath) => { - const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path; - // Resolve relative paths to absolute paths - return path.isAbsolute(pathStr) - ? pathStr - : path.join(projectPath, pathStr); - }) - .filter((p) => p); // Filter out any empty paths - } - private buildFeaturePrompt(feature: Feature): string { const title = this.extractTitleFromDescription(feature.description); @@ -1164,6 +981,7 @@ This helps parse your summary correctly in the output logs.`; featureId: string, prompt: string, abortController: AbortController, + projectPath: string, imagePaths?: string[], model?: string, previousContent?: string @@ -1171,7 +989,9 @@ This helps parse your summary correctly in the output logs.`; // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing if (process.env.AUTOMAKER_MOCK_AGENT === "true") { - console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`); + console.log( + `[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}` + ); // Simulate some work being done await this.sleep(500); @@ -1203,8 +1023,7 @@ This helps parse your summary correctly in the output logs.`; await this.sleep(200); // Save mock agent output - const configProjectPath = this.config?.projectPath || workDir; - const featureDirForOutput = getFeatureDir(configProjectPath, featureId); + const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); const mockOutput = `# Mock Agent Output @@ -1222,7 +1041,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, mockOutput); - console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`); + console.log( + `[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}` + ); return; } @@ -1273,10 +1094,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` : ""; // Agent output goes to .automaker directory - // Note: We use the original projectPath here (from config), not workDir - // because workDir might be a worktree path - const configProjectPath = this.config?.projectPath || workDir; - const featureDirForOutput = getFeatureDir(configProjectPath, featureId); + // Note: We use projectPath here, not workDir, because workDir might be a worktree path + const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); // Incremental file writing state @@ -1290,7 +1109,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await fs.writeFile(outputPath, responseText); } catch (error) { // Log but don't crash - file write errors shouldn't stop execution - console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); + console.error( + `[AutoMode] Failed to write agent output for ${featureId}:`, + error + ); } }; @@ -1309,11 +1131,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. for (const block of msg.message.content) { if (block.type === "text") { // Add separator before new text if we already have content and it doesn't end with newlines - if (responseText.length > 0 && !responseText.endsWith('\n\n')) { - if (responseText.endsWith('\n')) { - responseText += '\n'; + if (responseText.length > 0 && !responseText.endsWith("\n\n")) { + if (responseText.endsWith("\n")) { + responseText += "\n"; } else { - responseText += '\n\n'; + responseText += "\n\n"; } } responseText += block.text || ""; @@ -1347,12 +1169,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }); // Also add to file output for persistence - if (responseText.length > 0 && !responseText.endsWith('\n')) { - responseText += '\n'; + if (responseText.length > 0 && !responseText.endsWith("\n")) { + responseText += "\n"; } responseText += `\n🔧 Tool: ${block.name}\n`; if (block.input) { - responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + responseText += `Input: ${JSON.stringify( + block.input, + null, + 2 + )}\n`; } scheduleWrite(); } @@ -1382,12 +1208,68 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. context: string, useWorktrees: boolean ): Promise { - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); + if (this.runningFeatures.has(featureId)) { + throw new Error(`Feature ${featureId} is already running`); } - const prompt = `## Continuing Feature Implementation + const abortController = new AbortController(); + + // Emit feature start event early + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { + id: featureId, + title: "Resuming...", + description: "Feature is resuming from previous context", + }, + }); + + try { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Derive workDir from feature.branchName + let worktreePath: string | null = null; + const branchName = feature.branchName || null; + + if (useWorktrees && branchName) { + worktreePath = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); + if (!worktreePath) { + worktreePath = await this.setupWorktree( + projectPath, + featureId, + branchName + ); + } + console.log( + `[AutoMode] Resuming in worktree for branch "${branchName}": ${worktreePath}` + ); + } + + const workDir = worktreePath + ? path.resolve(worktreePath) + : path.resolve(projectPath); + + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath, + branchName, + abortController, + isAutoMode: false, + startTime: Date.now(), + }); + + // Update feature status to in_progress + await this.updateFeatureStatus(projectPath, featureId, "in_progress"); + + const prompt = `## Continuing Feature Implementation ${this.buildFeaturePrompt(feature)} @@ -1399,7 +1281,67 @@ ${context} ## Instructions Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; - return this.executeFeature(projectPath, featureId, useWorktrees, false); + // Extract image paths from feature + const imagePaths = feature.imagePaths?.map((img) => + typeof img === "string" ? img : img.path + ); + + // Get model from feature + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + console.log( + `[AutoMode] Resuming feature ${featureId} with model: ${model} in ${workDir}` + ); + + // Run the agent with context + await this.runAgent( + workDir, + featureId, + prompt, + abortController, + projectPath, + imagePaths, + model, + context // Pass previous context for proper file output + ); + + // Mark as waiting_approval for user review + await this.updateFeatureStatus( + projectPath, + featureId, + "waiting_approval" + ); + + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: true, + message: `Feature resumed and completed in ${Math.round( + (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + )}s`, + projectPath, + }); + } catch (error) { + const errorInfo = classifyError(error); + + if (errorInfo.isAbort) { + this.emitAutoModeEvent("auto_mode_feature_complete", { + featureId, + passes: false, + message: "Feature stopped by user", + projectPath, + }); + } else { + console.error(`[AutoMode] Feature ${featureId} resume failed:`, error); + await this.updateFeatureStatus(projectPath, featureId, "backlog"); + this.emitAutoModeEvent("auto_mode_error", { + featureId, + error: errorInfo.message, + errorType: errorInfo.isAuth ? "authentication" : "execution", + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } } /** @@ -1418,7 +1360,28 @@ Review the previous work and continue the implementation. If the feature appears }); } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + + // If signal is provided and already aborted, reject immediately + if (signal?.aborted) { + clearTimeout(timeout); + reject(new Error("Aborted")); + return; + } + + // Listen for abort signal + if (signal) { + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Aborted")); + }, + { once: true } + ); + } + }); } } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 67850f99..a8e55256 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -24,6 +24,8 @@ export interface Feature { spec?: string; model?: string; imagePaths?: Array; + // Branch info - worktree path is derived at runtime from branchName + branchName?: string; // Name of the feature branch (undefined = use current worktree) [key: string]: unknown; } diff --git a/package-lock.json b/package-lock.json index ed4a9293..c12edb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,10 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", @@ -1546,10 +1548,6 @@ "version": "1.1.1", "license": "MIT" }, - "apps/app/node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "license": "MIT" - }, "apps/app/node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "license": "MIT", @@ -1599,72 +1597,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "license": "MIT", @@ -1715,19 +1647,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", "license": "MIT", @@ -1816,22 +1735,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-label": { "version": "2.1.8", "license": "MIT", @@ -2031,94 +1934,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-slider": { "version": "1.3.6", "license": "MIT", @@ -2242,52 +2057,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.1", "license": "MIT", @@ -2304,32 +2073,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "apps/app/node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "license": "MIT", @@ -2346,22 +2089,6 @@ } } }, - "apps/app/node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "apps/app/node_modules/@radix-ui/react-visually-hidden": { "version": "1.2.3", "license": "MIT", @@ -11195,6 +10922,358 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",