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