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

@@ -0,0 +1,151 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface BranchAutocompleteProps {
value: string;
onChange: (value: string) => void;
branches: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function BranchAutocomplete({
value,
onChange,
branches,
placeholder = "Select a branch...",
className,
disabled = false,
"data-testid": testId,
}: BranchAutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
// Always include "main" at the top of suggestions
const allBranches = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
return Array.from(branchSet);
}, [branches]);
// Filter branches based on input
const filteredBranches = React.useMemo(() => {
if (!inputValue) return allBranches;
const lower = inputValue.toLowerCase();
return allBranches.filter((b) => b.toLowerCase().includes(lower));
}, [allBranches, inputValue]);
// Check if user typed a new branch name that doesn't exist
const isNewBranch =
inputValue.trim() &&
!allBranches.some((b) => b.toLowerCase() === inputValue.toLowerCase());
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-mono text-sm", className)}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate">
<GitBranch className="w-4 h-4 shrink-0 text-muted-foreground" />
{value || placeholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" data-testid="branch-autocomplete-list">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or type new branch..."
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>
{inputValue.trim() ? (
<div className="py-2 px-3 text-sm">
Press enter to create{" "}
<code className="bg-muted px-1 rounded">{inputValue}</code>
</div>
) : (
"No branches found."
)}
</CommandEmpty>
<CommandGroup>
{/* Show "Create new" option if typing a new branch name */}
{isNewBranch && (
<CommandItem
value={inputValue}
onSelect={() => {
onChange(inputValue);
setInputValue("");
setOpen(false);
}}
className="text-[var(--status-success)]"
data-testid="branch-option-create-new"
>
<GitBranch className="w-4 h-4 mr-2" />
Create &quot;{inputValue}&quot;
<span className="ml-auto text-xs text-muted-foreground">
(new)
</span>
</CommandItem>
)}
{filteredBranches.map((branch) => (
<CommandItem
key={branch}
value={branch}
onSelect={(currentValue) => {
onChange(currentValue);
setInputValue("");
setOpen(false);
}}
data-testid={`branch-option-${branch.replace(/[/\\]/g, "-")}`}
>
<GitBranch className="w-4 h-4 mr-2" />
{branch}
<Check
className={cn(
"ml-auto",
value === branch ? "opacity-100" : "opacity-0"
)}
/>
{branch === "main" && (
<span className="ml-2 text-xs text-muted-foreground">
(default)
</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -63,6 +63,7 @@ export function BoardView() {
specCreatingForProject,
setSpecCreatingForProject,
getCurrentWorktree,
setCurrentWorktree,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const {
@@ -208,6 +209,40 @@ export function BoardView() {
return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]);
// Branch suggestions for the branch autocomplete
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
// Fetch branches when project changes
useEffect(() => {
const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
setBranchSuggestions([]);
return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback(
(args: any) => {
@@ -287,6 +322,8 @@ export function BoardView() {
setShowFollowUpDialog,
inProgressFeaturesForShortcuts,
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use keyboard shortcuts hook (after actions hook)
@@ -299,8 +336,12 @@ export function BoardView() {
});
// Use drag and drop hook
// Get current worktree path for filtering features and assigning to cards
// Get current worktree path and branch for filtering features
const currentWorktreePath = currentProject ? getCurrentWorktree(currentProject.path) : null;
const worktrees = useAppStore((s) => currentProject ? s.getWorktrees(currentProject.path) : []);
const currentWorktreeBranch = currentWorktreePath
? worktrees.find(w => w.path === currentWorktreePath)?.branch || null
: null;
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
features: hookFeatures,
@@ -308,8 +349,8 @@ export function BoardView() {
runningAutoTasks,
persistFeatureUpdate,
handleStartImplementation,
currentWorktreePath,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
});
// Use column features hook
@@ -318,6 +359,7 @@ export function BoardView() {
runningAutoTasks,
searchQuery,
currentWorktreePath,
currentWorktreeBranch,
projectPath: currentProject?.path || null,
});
@@ -372,7 +414,7 @@ export function BoardView() {
{/* Worktree Selector */}
<WorktreeSelector
key={worktreeRefreshKey}
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
@@ -391,6 +433,8 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath }))}
/>
{/* Main Content Area */}
@@ -435,8 +479,6 @@ export function BoardView() {
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onCommit={handleCommitFeature}
onRevert={handleRevertFeature}
onMerge={handleMergeFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
featuresWithContext={featuresWithContext}
@@ -482,6 +524,7 @@ export function BoardView() {
onOpenChange={setShowAddDialog}
onAdd={handleAddFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
defaultSkipTests={defaultSkipTests}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
@@ -494,6 +537,7 @@ export function BoardView() {
onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
@@ -551,7 +595,11 @@ export function BoardView() {
open={showCreateWorktreeDialog}
onOpenChange={setShowCreateWorktreeDialog}
projectPath={currentProject.path}
onCreated={() => setWorktreeRefreshKey((k) => k + 1)}
onCreated={(worktreePath) => {
setWorktreeRefreshKey((k) => k + 1);
// Auto-select the newly created worktree
setCurrentWorktree(currentProject.path, worktreePath);
}}
/>
{/* Delete Worktree Dialog */}

View File

@@ -52,8 +52,6 @@ import {
MoreVertical,
AlertCircle,
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
Brain,
@@ -103,8 +101,6 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void;
onComplete?: () => void;
hasContext?: boolean;
@@ -130,8 +126,6 @@ export const KanbanCard = memo(function KanbanCard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
onImplement,
onComplete,
hasContext,
@@ -146,7 +140,6 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
@@ -621,6 +614,16 @@ export const KanbanCard = memo(function KanbanCard({
</CardHeader>
<CardContent className="p-3 pt-0">
{/* Target Branch Display */}
{feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}>
{feature.branchName}
</span>
</div>
)}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5">
@@ -953,30 +956,6 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[var(--status-error)] hover:text-[var(--status-error)] hover:bg-[var(--status-error-bg)] shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Refine prompt button */}
{onFollowUp && (
<Button
@@ -994,24 +973,7 @@ export const KanbanCard = memo(function KanbanCard({
<span className="truncate">Refine</span>
</Button>
)}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{!hasWorktree && onCommit && (
{onCommit && (
<Button
variant="default"
size="sm"
@@ -1121,53 +1083,6 @@ export const KanbanCard = memo(function KanbanCard({
</DialogContent>
</Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-[var(--status-error)]">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-[var(--status-error)] font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);

View File

@@ -27,6 +27,10 @@ import {
GitBranchPlus,
Check,
Search,
Play,
Square,
Globe,
Loader2,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
@@ -37,6 +41,8 @@ interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}
@@ -47,6 +53,17 @@ interface BranchInfo {
isRemote: boolean;
}
interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
}
interface FeatureInfo {
id: string;
worktreePath?: string;
}
interface WorktreeSelectorProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -54,6 +71,10 @@ interface WorktreeSelectorProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
/** Increment this to trigger a refresh without unmounting the component */
refreshTrigger?: number;
}
export function WorktreeSelector({
@@ -63,17 +84,23 @@ export function WorktreeSelector({
onCommit,
onCreatePR,
onCreateBranch,
runningFeatureIds = [],
features = [],
refreshTrigger = 0,
}: WorktreeSelectorProps) {
const [isLoading, setIsLoading] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
@@ -99,6 +126,25 @@ export function WorktreeSelector({
}
}, [projectPath, setWorktreesInStore]);
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.listDevServers) {
return;
}
const result = await api.worktree.listDevServers();
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
}
setRunningDevServers(serversMap);
}
} catch (error) {
console.error("Failed to fetch dev servers:", error);
}
}, []);
const fetchBranches = useCallback(async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
@@ -122,12 +168,198 @@ export function WorktreeSelector({
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
fetchDevServers();
}, [fetchWorktrees, fetchDevServers]);
const handleSelectWorktree = (worktree: WorktreeInfo) => {
// Refresh when refreshTrigger changes (but skip the initial render)
useEffect(() => {
if (refreshTrigger > 0) {
fetchWorktrees();
}
}, [refreshTrigger, fetchWorktrees]);
// Initialize selection to main if not set
useEffect(() => {
if (worktrees.length > 0 && currentWorktree === undefined) {
setCurrentWorktree(projectPath, null); // null = main worktree
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
// Simply select the worktree in the UI
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path);
};
const handleStartDevServer = async (worktree: WorktreeInfo) => {
if (isStartingDevServer) return;
setIsStartingDevServer(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.startDevServer) {
toast.error("Start dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
// Update running servers map
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(targetPath, {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
} else {
toast.error(result.error || "Failed to start dev server");
}
} catch (error) {
console.error("Start dev server failed:", error);
toast.error("Failed to start dev server");
} finally {
setIsStartingDevServer(false);
}
};
const handleStopDevServer = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.stopDevServer) {
toast.error("Stop dev server API not available");
return;
}
// Use projectPath for main, worktree.path for others
const targetPath = worktree.isMain ? projectPath : worktree.path;
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
// Update running servers map
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(targetPath);
return next;
});
toast.success(result.result?.message || "Dev server stopped");
} else {
toast.error(result.error || "Failed to stop dev server");
}
} catch (error) {
console.error("Stop dev server failed:", error);
toast.error("Failed to stop dev server");
}
};
const handleOpenDevServerUrl = (worktree: WorktreeInfo) => {
const targetPath = worktree.isMain ? projectPath : worktree.path;
const serverInfo = runningDevServers.get(targetPath);
if (serverInfo) {
window.open(serverInfo.url, "_blank");
}
};
// Helper to get the path key for a worktree (for looking up in runningDevServers)
const getWorktreeKey = (worktree: WorktreeInfo) => {
return worktree.isMain ? projectPath : worktree.path;
};
// Helper to check if a worktree has running features
const hasRunningFeatures = (worktree: WorktreeInfo) => {
if (runningFeatureIds.length === 0) return false;
const worktreeKey = getWorktreeKey(worktree);
// Check if any running feature belongs to this worktree
return runningFeatureIds.some((featureId) => {
const feature = features.find((f) => f.id === featureId);
if (!feature) return false;
// For main worktree, check features with no worktreePath or matching projectPath
if (worktree.isMain) {
return !feature.worktreePath || feature.worktreePath === projectPath;
}
// For other worktrees, check if worktreePath matches
return feature.worktreePath === worktreeKey;
});
};
const handleActivateWorktree = async (worktree: WorktreeInfo) => {
if (isActivating) return;
setIsActivating(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.activate) {
toast.error("Activate worktree API not available");
return;
}
const result = await api.worktree.activate(projectPath, worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
// After activation, refresh to show updated state
fetchWorktrees();
} else {
toast.error(result.error || "Failed to activate worktree");
}
} catch (error) {
console.error("Activate worktree failed:", error);
toast.error("Failed to activate worktree");
} finally {
setIsActivating(false);
}
};
const handleSwitchToBranch = async (branchName: string) => {
if (isActivating) return;
setIsActivating(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.activate) {
toast.error("Activate API not available");
return;
}
// Pass null as worktreePath to switch to a branch without a worktree
// We'll need to update the activate endpoint to handle this case
const result = await api.worktree.switchBranch(projectPath, branchName);
if (result.success && result.result) {
toast.success(result.result.message);
fetchWorktrees();
} else {
toast.error(result.error || "Failed to switch branch");
}
} catch (error) {
console.error("Switch branch failed:", error);
toast.error("Failed to switch branch");
} finally {
setIsActivating(false);
}
};
const handleOpenInEditor = async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
console.warn("Open in editor API not available");
return;
}
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
console.error("Open in editor failed:", error);
}
};
const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
@@ -204,10 +436,11 @@ export function WorktreeSelector({
}
};
const selectedWorktree =
worktrees.find((w) =>
currentWorktree ? w.path === currentWorktree : w.isMain
) || worktrees.find((w) => w.isMain);
// The "selected" worktree is based on UI state, not git's current branch
// currentWorktree is null for main, or the worktree path for others
const selectedWorktree = currentWorktree
? worktrees.find((w) => w.path === currentWorktree)
: worktrees.find((w) => w.isMain);
if (worktrees.length === 0 && !isLoading) {
// No git repo or loading
@@ -216,116 +449,146 @@ export function WorktreeSelector({
// Render a worktree tab with branch selector (for main) and actions dropdown
const renderWorktreeTab = (worktree: WorktreeInfo) => {
const isSelected = selectedWorktree?.path === worktree.path;
// Selection is based on UI state, not git's current branch
// Default to main selected if currentWorktree is null or undefined
const isSelected = worktree.isMain
? currentWorktree === null || currentWorktree === undefined
: worktree.path === currentWorktree;
const isRunning = hasRunningFeatures(worktree);
return (
<div key={worktree.path} className="flex items-center">
{/* Branch name - clickable dropdown for main repo to switch branches */}
{/* Main branch: clickable button + separate branch switch dropdown */}
{worktree.isMain ? (
<DropdownMenu onOpenChange={(open) => {
if (open) {
// Select this worktree when opening the dropdown
if (!isSelected) {
handleSelectWorktree(worktree);
<>
{/* Clickable button to select/preview main */}
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
</Button>
{/* Branch switch dropdown button */}
<DropdownMenu onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
setBranchFilter("");
}
fetchBranches(worktree.path);
setBranchFilter("");
}
}}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-r-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "hover:bg-secondary"
)}
>
<GitBranch className="w-3 h-3" />
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{worktree.changedFilesCount}
</span>
)}
<ChevronDown className="w-3 h-3 ml-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{/* Search input */}
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => setBranchFilter(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
}}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Search input */}
<div className="px-2 py-1.5">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter branches..."
value={branchFilter}
onChange={(e) => setBranchFilter(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
autoFocus
/>
</div>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
) : (() => {
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
if (filteredBranches.length === 0) {
return (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
</DropdownMenuItem>
);
}
return filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => handleSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
Loading branches...
</DropdownMenuItem>
));
})()}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (() => {
const filteredBranches = branches.filter((b) =>
b.name.toLowerCase().includes(branchFilter.toLowerCase())
);
if (filteredBranches.length === 0) {
return (
<DropdownMenuItem disabled className="text-xs">
{branchFilter ? "No matching branches" : "No branches found"}
</DropdownMenuItem>
);
}
return filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => handleSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
));
})()}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateBranch(worktree)}
className="text-xs"
>
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
Create New Branch...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
// Non-main worktrees - just show branch name (worktrees are tied to branches)
// Non-main branches - click to switch to this branch
<Button
variant={isSelected ? "default" : "ghost"}
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 px-3 text-xs font-mono gap-1.5 rounded-r-none",
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "hover:bg-secondary"
!isSelected && "bg-secondary/50 hover:bg-secondary",
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title={worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{worktree.branch}
{worktree.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -335,6 +598,24 @@ export function WorktreeSelector({
</Button>
)}
{/* Dev server indicator */}
{runningDevServers.has(getWorktreeKey(worktree)) && (
<Button
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-7 p-0 rounded-none border-r-0",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "bg-secondary/50 hover:bg-secondary",
"text-green-500"
)}
onClick={() => handleOpenDevServerUrl(worktree)}
title={`Open dev server (port ${runningDevServers.get(getWorktreeKey(worktree))?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
{/* Actions dropdown */}
<DropdownMenu onOpenChange={(open) => {
if (open) {
@@ -343,18 +624,54 @@ export function WorktreeSelector({
}}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "ghost"}
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"h-7 w-6 p-0 rounded-l-none",
"h-7 w-7 p-0 rounded-l-none",
isSelected && "bg-primary text-primary-foreground",
!isSelected && "hover:bg-secondary"
!isSelected && "bg-secondary/50 hover:bg-secondary"
)}
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Dev server controls */}
{runningDevServers.has(getWorktreeKey(worktree)) ? (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{runningDevServers.get(getWorktreeKey(worktree))?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleOpenDevServerUrl(worktree)}
className="text-xs"
>
<Globe className="w-3.5 h-3.5 mr-2" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStopDevServer(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<Square className="w-3.5 h-3.5 mr-2" />
Stop Dev Server
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : (
<>
<DropdownMenuItem
onClick={() => handleStartDevServer(worktree)}
disabled={isStartingDevServer}
className="text-xs"
>
<Play className={cn("w-3.5 h-3.5 mr-2", isStartingDevServer && "animate-pulse")} />
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Pull option */}
<DropdownMenuItem
onClick={() => handlePull(worktree)}
@@ -384,6 +701,15 @@ export function WorktreeSelector({
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Open in editor */}
<DropdownMenuItem
onClick={() => handleOpenInEditor(worktree)}
className="text-xs"
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in Editor
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Commit changes */}
{worktree.hasChanges && (
<DropdownMenuItem

View File

@@ -14,6 +14,7 @@ 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,
@@ -47,8 +48,10 @@ interface AddFeatureDialogProps {
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
defaultSkipTests: boolean;
isMaximized: boolean;
showProfilesOnly: boolean;
@@ -60,6 +63,7 @@ export function AddFeatureDialog({
onOpenChange,
onAdd,
categorySuggestions,
branchSuggestions,
defaultSkipTests,
isMaximized,
showProfilesOnly,
@@ -74,6 +78,7 @@ export function AddFeatureDialog({
skipTests: false,
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
@@ -111,6 +116,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
});
// Reset form
@@ -123,6 +129,7 @@ export function AddFeatureDialog({
skipTests: defaultSkipTests,
model: "opus",
thinkingLevel: "none",
branchName: "main",
});
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
@@ -237,6 +244,21 @@ export function AddFeatureDialog({
data-testid="feature-category-input"
/>
</div>
<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>
</TabsContent>
{/* Model Tab */}

View File

@@ -20,7 +20,7 @@ interface CreateWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
onCreated: () => void;
onCreated: (worktreePath: string) => void;
}
export function CreateWorktreeDialog({
@@ -68,7 +68,7 @@ export function CreateWorktreeDialog({
: "Using existing branch",
}
);
onCreated();
onCreated(result.worktree.path);
onOpenChange(false);
setBranchName("");
} else {

View File

@@ -14,6 +14,7 @@ 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,
@@ -47,9 +48,11 @@ interface EditFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
}
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
@@ -60,6 +63,7 @@ export function EditFeatureDialog({
onClose,
onUpdate,
categorySuggestions,
branchSuggestions,
isMaximized,
showProfilesOnly,
aiProfiles,
@@ -93,6 +97,7 @@ export function EditFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
};
onUpdate(editingFeature.id, updates);
@@ -214,6 +219,32 @@ export function EditFeatureDialog({
data-testid="edit-feature-category"
/>
</div>
<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>
</TabsContent>
{/* Model Tab */}

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,
]
);

View File

@@ -45,8 +45,6 @@ interface KanbanBoardProps {
onMoveBackToInProgress: (feature: Feature) => void;
onFollowUp: (feature: Feature) => void;
onCommit: (feature: Feature) => void;
onRevert: (feature: Feature) => void;
onMerge: (feature: Feature) => void;
onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void;
featuresWithContext: Set<string>;
@@ -77,8 +75,6 @@ export function KanbanBoard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
onComplete,
onImplement,
featuresWithContext,
@@ -191,8 +187,6 @@ export function KanbanBoard({
}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onRevert={() => onRevert(feature)}
onMerge={() => onMerge(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
hasContext={featuresWithContext.has(feature.id)}

View File

@@ -224,7 +224,8 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
verifyFeature: (
projectPath: string,
@@ -245,11 +246,13 @@ export interface AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
commitFeature: (
projectPath: string,
featureId: string
featureId: string,
worktreePath?: string
) => Promise<{ success: boolean; error?: string }>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}
@@ -1056,7 +1059,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
return {
success: true,
worktrees: [
{ path: projectPath, branch: "main", isMain: true, hasChanges: false, changedFilesCount: 0 },
{ path: projectPath, branch: "main", isMain: true, isCurrent: true, hasWorktree: true, hasChanges: false, changedFilesCount: 0 },
],
};
},
@@ -1104,6 +1107,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
result: {
branch: "feature-branch",
pushed: true,
message: "Successfully pushed to origin/feature-branch",
},
};
},
@@ -1205,6 +1209,73 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
openInEditor: async (worktreePath: string) => {
console.log("[Mock] Opening in editor:", worktreePath);
return {
success: true,
result: {
message: `Opened ${worktreePath} in editor`,
},
};
},
initGit: async (projectPath: string) => {
console.log("[Mock] Initializing git:", projectPath);
return {
success: true,
result: {
initialized: true,
message: `Initialized git repository in ${projectPath}`,
},
};
},
activate: async (projectPath: string, worktreePath: string | null) => {
console.log("[Mock] Activating worktree:", { projectPath, worktreePath });
return {
success: true,
result: {
previousBranch: "main",
currentBranch: worktreePath ? "feature-branch" : "main",
message: worktreePath ? "Switched to worktree branch" : "Switched to main",
},
};
},
startDevServer: async (projectPath: string, worktreePath: string) => {
console.log("[Mock] Starting dev server:", { projectPath, worktreePath });
return {
success: true,
result: {
worktreePath,
port: 3001,
url: "http://localhost:3001",
message: "Dev server started on port 3001",
},
};
},
stopDevServer: async (worktreePath: string) => {
console.log("[Mock] Stopping dev server:", worktreePath);
return {
success: true,
result: {
worktreePath,
message: "Dev server stopped",
},
};
},
listDevServers: async () => {
console.log("[Mock] Listing dev servers");
return {
success: true,
result: {
servers: [],
},
};
},
};
}
@@ -1311,7 +1382,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
runFeature: async (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => {
if (mockRunningFeatures.has(featureId)) {
return {
@@ -1321,7 +1393,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
}
console.log(
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}`
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
);
mockRunningFeatures.add(featureId);
simulateAutoModeLoop(projectPath, featureId);
@@ -1481,7 +1553,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => {
if (mockRunningFeatures.has(featureId)) {
return {
@@ -1506,8 +1579,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true };
},
commitFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Committing feature:", { projectPath, featureId });
commitFeature: async (projectPath: string, featureId: string, worktreePath?: string) => {
console.log("[Mock] Committing feature:", { projectPath, featureId, worktreePath });
// Simulate commit operation
emitAutoModeEvent({

View File

@@ -509,12 +509,14 @@ export class HttpApiClient implements ElectronAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
@@ -528,16 +530,18 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId, worktreePath }),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
@@ -586,6 +590,18 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/worktree/list-branches", { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
activate: (projectPath: string, worktreePath: string | null) =>
this.post("/api/worktree/activate", { projectPath, worktreePath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () =>
this.post("/api/worktree/list-dev-servers", {}),
};
// Git API

View File

@@ -48,6 +48,31 @@ export async function initializeProject(
const existingFiles: string[] = [];
try {
// Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) {
console.log("[project-init] Initializing git repository...");
try {
// Initialize git and create an initial empty commit via server route
const result = await api.worktree?.initGit(projectPath);
if (result?.success && result.result?.initialized) {
createdFiles.push(".git");
console.log("[project-init] Git repository initialized with initial commit");
} else if (result?.success && !result.result?.initialized) {
// Git already existed (shouldn't happen since we checked, but handle it)
existingFiles.push(".git");
console.log("[project-init] Git repository already exists");
} else {
console.warn("[project-init] Failed to initialize git repository:", result?.error);
}
} catch (gitError) {
console.warn("[project-init] Failed to initialize git repository:", gitError);
// Don't fail the whole initialization if git init fails
}
} else {
existingFiles.push(".git");
}
// Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;

View File

@@ -340,7 +340,8 @@ export interface AutoModeAPI {
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
useWorktrees?: boolean,
worktreePath?: string
) => Promise<{
success: boolean;
passes?: boolean;
@@ -384,7 +385,8 @@ export interface AutoModeAPI {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
worktreePath?: string
) => Promise<{
success: boolean;
passes?: boolean;
@@ -631,6 +633,8 @@ export interface WorktreeAPI {
path: string;
branch: string;
isMain: boolean;
isCurrent: boolean; // Is this the currently checked out branch?
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
}>;
@@ -690,6 +694,7 @@ export interface WorktreeAPI {
result?: {
branch: string;
pushed: boolean;
message: string;
};
error?: string;
}>;
@@ -785,6 +790,79 @@ export interface WorktreeAPI {
};
error?: string;
}>;
// Open a worktree directory in the editor
openInEditor: (worktreePath: string) => Promise<{
success: boolean;
result?: {
message: string;
};
error?: string;
}>;
// Initialize git repository in a project
initGit: (projectPath: string) => Promise<{
success: boolean;
result?: {
initialized: boolean;
message: string;
};
error?: string;
}>;
// Activate a worktree (switch main project to that branch)
activate: (
projectPath: string,
worktreePath: string | null
) => Promise<{
success: boolean;
result?: {
previousBranch: string;
currentBranch: string;
message: string;
};
error?: string;
}>;
// Start a dev server for a worktree
startDevServer: (
projectPath: string,
worktreePath: string
) => Promise<{
success: boolean;
result?: {
worktreePath: string;
port: number;
url: string;
message: string;
};
error?: string;
}>;
// Stop a dev server for a worktree
stopDevServer: (
worktreePath: string
) => Promise<{
success: boolean;
result?: {
worktreePath: string;
message: string;
};
error?: string;
}>;
// List all running dev servers
listDevServers: () => Promise<{
success: boolean;
result?: {
servers: Array<{
worktreePath: string;
port: number;
url: string;
}>;
};
error?: string;
}>;
}
export interface GitAPI {