Implement branch selection and worktree management features

- Added a new BranchAutocomplete component for selecting branches in feature dialogs.
- Enhanced BoardView to fetch and display branch suggestions.
- Updated CreateWorktreeDialog and EditFeatureDialog to include branch selection.
- Modified worktree management to ensure proper handling of branch-specific worktrees.
- Refactored related components and hooks to support the new branch management functionality.
- Removed unused revert and merge handlers from Kanban components for cleaner code.
This commit is contained in:
Cody Seibert
2025-12-16 12:12:10 -05:00
parent 54a102f029
commit a3c9c9cee5
52 changed files with 2969 additions and 588 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
@@ -28,6 +28,8 @@ interface UseBoardActionsProps {
setShowFollowUpDialog: (show: boolean) => void;
inProgressFeaturesForShortcuts: Feature[];
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
}
export function useBoardActions({
@@ -52,10 +54,67 @@ export function useBoardActions({
setShowFollowUpDialog,
inProgressFeaturesForShortcuts,
outputFeature,
projectPath,
onWorktreeCreated,
}: UseBoardActionsProps) {
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = 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]
);
const handleAddFeature = useCallback(
(featureData: {
category: string;
@@ -66,6 +125,7 @@ export function useBoardActions({
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
}) => {
const newFeatureData = {
...featureData,
@@ -89,6 +149,7 @@ export function useBoardActions({
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
}
) => {
updateFeature(featureId, updates);
@@ -155,14 +216,19 @@ 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;
const result = await api.autoMode.runFeature(
currentProject.path,
feature.id,
useWorktrees
useWorktrees,
featureWorktreePath || undefined
);
if (result.success) {
console.log("[Board] Feature run started successfully");
console.log("[Board] Feature run started successfully in worktree:", featureWorktreePath || "main");
} else {
console.error("[Board] Failed to run feature:", result.error);
await loadFeatures();
@@ -327,8 +393,10 @@ 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;
api.autoMode
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths)
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths, featureWorktreePath)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
@@ -365,7 +433,8 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.commitFeature(currentProject.path, feature.id);
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
const result = await api.autoMode.commitFeature(currentProject.path, feature.id, feature.worktreePath);
if (result.success) {
moveFeature(feature.id, "verified");
@@ -373,6 +442,8 @@ export function useBoardActions({
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
} else {
console.error("[Board] Failed to commit feature:", result.error);
toast.error("Failed to commit feature", {
@@ -388,7 +459,7 @@ export function useBoardActions({
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures]
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);
const handleRevertFeature = useCallback(
@@ -565,12 +636,29 @@ export function useBoardActions({
return;
}
const featuresToStart = backlogFeatures.slice(0, availableSlots);
if (backlogFeatures.length === 0) {
toast.info("Backlog empty", {
description: "No features in backlog to start.",
});
return;
}
// Start only one feature per keypress (user must press again for next)
const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) {
await handleStartImplementation(feature);
// Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress)
const 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 });
}
}, [features, runningAutoTasks, handleStartImplementation]);
}, [features, runningAutoTasks, handleStartImplementation, getOrCreateWorktreeForFeature, persistFeatureUpdate, onWorktreeCreated]);
const handleDeleteAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");

View File

@@ -8,6 +8,7 @@ interface UseBoardColumnFeaturesProps {
runningAutoTasks: string[];
searchQuery: string;
currentWorktreePath: string | null; // Currently selected worktree path
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
projectPath: string | null; // Main project path (for main worktree)
}
@@ -16,6 +17,7 @@ export function useBoardColumnFeatures({
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath,
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
@@ -38,18 +40,32 @@ export function useBoardColumnFeatures({
)
: features;
// Determine the effective worktree path for filtering
// If currentWorktreePath is null, we're on the main worktree (use projectPath)
// Determine the effective worktree path and branch for filtering
// If currentWorktreePath is null, we're on the main worktree
const effectiveWorktreePath = currentWorktreePath || projectPath;
const effectiveBranch = currentWorktreeBranch || "main";
filteredFeatures.forEach((f) => {
// 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
// Features without a worktreePath are considered unassigned (backlog items)
// Features with a worktreePath should only show if it matches the selected worktree
const matchesWorktree = !f.worktreePath || f.worktreePath === effectiveWorktreePath;
// Match by worktreePath if set, OR by branchName if set
// Features with neither are considered unassigned (show on main only)
const featureBranch = f.branchName || "main";
const hasWorktreeAssigned = f.worktreePath || f.branchName;
let matchesWorktree: boolean;
if (!hasWorktreeAssigned) {
// No worktree or branch assigned - show only on main
matchesWorktree = !currentWorktreePath;
} else if (f.worktreePath) {
// Has worktreePath - match by path
matchesWorktree = f.worktreePath === effectiveWorktreePath;
} else {
// Has branchName but no worktreePath - match by branch name
matchesWorktree = featureBranch === effectiveBranch;
}
if (isRunning) {
// Only show running tasks if they match the current worktree
@@ -84,7 +100,7 @@ export function useBoardColumnFeatures({
});
return map;
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, projectPath]);
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {

View File

@@ -4,6 +4,7 @@ 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[];
@@ -14,8 +15,8 @@ interface UseBoardDragDropProps {
updates: Partial<Feature>
) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>;
currentWorktreePath: string | null; // Currently selected worktree path
projectPath: string | null; // Main project path
onWorktreeCreated?: () => void; // Callback when a new worktree is created
}
export function useBoardDragDrop({
@@ -24,14 +25,66 @@ export function useBoardDragDrop({
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
currentWorktreePath,
projectPath,
onWorktreeCreated,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore();
// Determine the effective worktree path for assigning to features
const effectiveWorktreePath = currentWorktreePath || projectPath;
/**
* 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]
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -104,12 +157,16 @@ export function useBoardDragDrop({
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
// Assign the current worktree to this feature when moving to in_progress
if (effectiveWorktreePath) {
await persistFeatureUpdate(featureId, { worktreePath: effectiveWorktreePath });
// Get or create worktree based on the feature's assigned branch
const worktreePath = await getOrCreateWorktreeForFeature(draggedFeature);
if (worktreePath) {
await persistFeatureUpdate(featureId, { worktreePath });
}
// Always refresh worktree selector after moving to in_progress
onWorktreeCreated?.();
// Use helper function to handle concurrency check and start implementation
await handleStartImplementation(draggedFeature);
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...draggedFeature, worktreePath: worktreePath || undefined });
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -219,7 +276,8 @@ export function useBoardDragDrop({
moveFeature,
persistFeatureUpdate,
handleStartImplementation,
effectiveWorktreePath,
getOrCreateWorktreeForFeature,
onWorktreeCreated,
]
);