feat: enhance worktree management and UI integration

- Refactored BoardView and WorktreeSelector components for improved readability and maintainability, including consistent formatting and structure.
- Updated feature handling to ensure correct worktree assignment and reset logic when worktrees are deleted, enhancing user experience.
- Enhanced KanbanCard to display priority badges with improved styling and layout.
- Removed deprecated revert feature logic from the server and client, streamlining the codebase.
- Introduced new tests for feature lifecycle and worktree integration, ensuring robust functionality and error handling.
This commit is contained in:
Cody Seibert
2025-12-16 21:49:33 -05:00
parent f9ec7222f2
commit 58d6ae02a5
20 changed files with 2316 additions and 504 deletions

View File

@@ -10,6 +10,7 @@ import {
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
@@ -51,7 +52,9 @@ import {
} from "./board-view/hooks";
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
export function BoardView() {
const {
@@ -95,9 +98,12 @@ export function BoardView() {
useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
@@ -251,31 +257,25 @@ export function BoardView() {
}, [currentProject, worktreeRefreshKey]);
// Custom collision detection that prioritizes columns over cards
const collisionDetectionStrategy = useCallback(
(args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
// If we found a column collision, use that
if (columnCollisions.length > 0) {
return columnCollisions;
}
// If we found a column collision, use that
if (columnCollisions.length > 0) {
return columnCollisions;
}
// Otherwise, use rectangle intersection for cards
return rectIntersection(args);
},
[]
);
// Otherwise, use rectangle intersection for cards
return rectIntersection(args);
}, []);
// Use persistence hook
const {
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
} = useBoardPersistence({ currentProject });
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -287,20 +287,24 @@ export function BoardView() {
// Get current worktree info (path and branch) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
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(
() => (currentProject ? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES),
() =>
currentProject
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// 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
const selectedWorktreeBranch = currentWorktreeBranch
|| worktrees.find(w => w.isMain)?.branch
|| "main";
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
// Extract all action handlers into a hook
const {
@@ -315,7 +319,6 @@ export function BoardView() {
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleRevertFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,
@@ -452,7 +455,11 @@ export function BoardView() {
setShowCreateBranchDialog(true);
}}
runningFeatureIds={runningAutoTasks}
features={hookFeatures.map(f => ({ id: f.id, worktreePath: f.worktreePath, branchName: f.branchName }))}
features={hookFeatures.map((f) => ({
id: f.id,
worktreePath: f.worktreePath,
branchName: f.branchName,
}))}
/>
{/* Main Content Area */}
@@ -626,10 +633,17 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
// Now set the current worktree with both path and branch
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
@@ -642,7 +656,24 @@ export function BoardView() {
onOpenChange={setShowDeleteWorktreeDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction}
onDeleted={() => {
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree
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
persistFeatureUpdate(feature.id, {
branchName: null as unknown as string | undefined,
worktreePath: null as unknown as string | undefined,
});
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}

View File

@@ -328,8 +328,8 @@ export const KanbanCard = memo(function KanbanCard({
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
"top-2 left-2 min-w-[36px]",
"absolute px-2 rounded-md z-10",
"top-2 left-2",
feature.priority === 1 &&
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
feature.priority === 2 &&
@@ -337,9 +337,22 @@ export const KanbanCard = memo(function KanbanCard({
feature.priority === 3 &&
"bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
)}
style={{ height: "28px" }}
data-testid={`priority-badge-${feature.id}`}
>
P{feature.priority}
{Array.from({ length: 4 - feature.priority }).map((_, i) => (
<ChevronUp
key={i}
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
top: `${2 + i * 3}px`,
width: "12px",
height: "12px",
}}
/>
))}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
@@ -347,8 +360,8 @@ export const KanbanCard = memo(function KanbanCard({
{feature.priority === 1
? "High Priority"
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? "Medium Priority"
: "Low Priority"}
</p>
</TooltipContent>
</Tooltip>
@@ -1095,7 +1108,6 @@ export const KanbanCard = memo(function KanbanCard({
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);

View File

@@ -101,7 +101,9 @@ export function WorktreeSelector({
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState("");
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
const [runningDevServers, setRunningDevServers] = useState<
Map<string, DevServerInfo>
>(new Map());
const [defaultEditorName, setDefaultEditorName] = useState<string>("Editor");
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
@@ -197,18 +199,37 @@ export function WorktreeSelector({
}
}, [refreshTrigger, fetchWorktrees]);
// Initialize selection to main if not set
// Initialize selection to main if not set OR if the stored worktree no longer exists
// This handles stale data (e.g., a worktree that was deleted)
useEffect(() => {
if (worktrees.length > 0 && currentWorktree === undefined) {
const mainWorktree = worktrees.find(w => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
// Check if the currently selected worktree still exists
// null path means main (which always exists if worktrees has items)
// Non-null path means we need to verify it exists in the worktrees list
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
// Reset to main if:
// 1. No worktree is set (currentWorktree is null/undefined)
// 2. Current worktree has a path that doesn't exist in the list (stale data)
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || "main";
setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
const handleSelectWorktree = async (worktree: WorktreeInfo) => {
// Simply select the worktree in the UI with both path and branch
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
setCurrentWorktree(
projectPath,
worktree.isMain ? null : worktree.path,
worktree.branch
);
};
const handleStartDevServer = async (worktree: WorktreeInfo) => {
@@ -326,57 +347,6 @@ export function WorktreeSelector({
});
};
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();
@@ -395,7 +365,10 @@ export function WorktreeSelector({
}
};
const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => {
const handleSwitchBranch = async (
worktree: WorktreeInfo,
branchName: string
) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
@@ -478,13 +451,14 @@ export function WorktreeSelector({
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);
// Render a worktree tab with branch selector (for main) and actions dropdown
const renderWorktreeTab = (worktree: WorktreeInfo) => {
// Selection is based on UI state, not git's current branch
// Default to main selected if currentWorktree is null/undefined or path is null
const isSelected = worktree.isMain
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
? currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
: pathsEqual(worktree.path, currentWorktreePath);
const isRunning = hasRunningFeatures(worktree);
@@ -508,7 +482,9 @@ export function WorktreeSelector({
title="Click to preview main"
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw 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">
@@ -517,12 +493,14 @@ export function WorktreeSelector({
)}
</Button>
{/* Branch switch dropdown button */}
<DropdownMenu onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
setBranchFilter("");
}
}}>
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
setBranchFilter("");
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
@@ -538,7 +516,9 @@ export function WorktreeSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs">
Switch Branch
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Search input */}
<div className="px-2 py-1.5">
@@ -563,33 +543,43 @@ export function WorktreeSelector({
<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>
) : (
(() => {
const filteredBranches = branches.filter((b) =>
b.name
.toLowerCase()
.includes(branchFilter.toLowerCase())
);
}
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>
));
})()}
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
@@ -615,12 +605,16 @@ export function WorktreeSelector({
)}
onClick={() => handleSelectWorktree(worktree)}
disabled={isActivating}
title={worktree.hasWorktree
? "Click to switch to this worktree's branch"
: "Click to switch to this branch"}
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" />}
{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">
@@ -642,18 +636,22 @@ export function WorktreeSelector({
"text-green-500"
)}
onClick={() => handleOpenDevServerUrl(worktree)}
title={`Open dev server (port ${runningDevServers.get(getWorktreeKey(worktree))?.port})`}
title={`Open dev server (port ${
runningDevServers.get(getWorktreeKey(worktree))?.port
})`}
>
<Globe className="w-3 h-3" />
</Button>
)}
{/* Actions dropdown */}
<DropdownMenu onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
}
}}>
<DropdownMenu
onOpenChange={(open) => {
if (open) {
fetchBranches(worktree.path);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? "default" : "outline"}
@@ -673,7 +671,8 @@ export function WorktreeSelector({
<>
<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})
Dev Server Running (:
{runningDevServers.get(getWorktreeKey(worktree))?.port})
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleOpenDevServerUrl(worktree)}
@@ -698,7 +697,12 @@ export function WorktreeSelector({
disabled={isStartingDevServer}
className="text-xs"
>
<Play className={cn("w-3.5 h-3.5 mr-2", isStartingDevServer && "animate-pulse")} />
<Play
className={cn(
"w-3.5 h-3.5 mr-2",
isStartingDevServer && "animate-pulse"
)}
/>
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -710,7 +714,9 @@ export function WorktreeSelector({
disabled={isPulling}
className="text-xs"
>
<Download className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")} />
<Download
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
/>
{isPulling ? "Pulling..." : "Pull"}
{behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
@@ -724,7 +730,9 @@ export function WorktreeSelector({
disabled={isPushing || aheadCount === 0}
className="text-xs"
>
<Upload className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")} />
<Upload
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
/>
{isPushing ? "Pushing..." : "Push"}
{aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
@@ -815,7 +823,9 @@ export function WorktreeSelector({
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn("w-3.5 h-3.5", isLoading && "animate-spin")} />
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</div>

View File

@@ -29,7 +29,7 @@ interface DeleteWorktreeDialogProps {
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onDeleted: () => void;
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
}
export function DeleteWorktreeDialog({
@@ -64,7 +64,7 @@ export function DeleteWorktreeDialog({
? `Branch "${worktree.branch}" was also deleted`
: `Branch "${worktree.branch}" was kept`,
});
onDeleted();
onDeleted(worktree, deleteBranch);
onOpenChange(false);
setDeleteBranch(false);
} else {

View File

@@ -137,7 +137,7 @@ export function useBoardActions({
);
const handleAddFeature = useCallback(
(featureData: {
async (featureData: {
category: string;
description: string;
steps: string[];
@@ -149,19 +149,38 @@ export function useBoardActions({
branchName: string;
priority: number;
}) => {
let worktreePath: string | undefined;
// If worktrees are enabled and a non-main branch is selected, create the worktree
if (useWorktrees && featureData.branchName) {
const branchName = featureData.branchName;
if (branchName !== "main" && branchName !== "master") {
// Create a temporary feature-like object for getOrCreateWorktreeForFeature
const tempFeature = { branchName } as Feature;
const path = await getOrCreateWorktreeForFeature(tempFeature);
if (path && path !== projectPath) {
worktreePath = path;
// Refresh worktree selector after creating worktree
onWorktreeCreated?.();
}
}
}
const newFeatureData = {
...featureData,
status: "backlog" as const,
worktreePath,
};
const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature);
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
},
[addFeature, persistFeatureCreate, saveCategory]
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
);
const handleUpdateFeature = useCallback(
(
async (
featureId: string,
updates: {
category: string;
@@ -175,14 +194,53 @@ export function useBoardActions({
priority: number;
}
) => {
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
// 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;
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;
}
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
if (updates.category) {
saveCategory(updates.category);
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature]
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated]
);
const handleDeleteFeature = useCallback(
@@ -291,7 +349,8 @@ export function useBoardActions({
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
// Must await to ensure feature status is persisted before starting agent
await persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature);
return true;
@@ -535,50 +594,6 @@ export function useBoardActions({
]
);
const handleRevertFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(
currentProject.path,
feature.id
);
if (result.success) {
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
toast.error("Failed to revert feature", {
description: result.error || "An error occurred",
});
}
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
[currentProject, loadFeatures]
);
const handleMergeFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
@@ -698,7 +713,8 @@ export function useBoardActions({
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus });
// Must await to ensure file is written before user can restart
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
@@ -733,8 +749,16 @@ export function useBoardActions({
// If no worktree is selected (currentWorktreeBranch is null or main-like),
// show features with no branch or "main"/"master" branch
if (!currentWorktreeBranch || currentWorktreeBranch === "main" || currentWorktreeBranch === "master") {
return !f.branchName || featureBranch === "main" || featureBranch === "master";
if (
!currentWorktreeBranch ||
currentWorktreeBranch === "main" ||
currentWorktreeBranch === "master"
) {
return (
!f.branchName ||
featureBranch === "main" ||
featureBranch === "master"
);
}
// Otherwise, only show features matching the selected worktree branch
@@ -754,9 +778,12 @@ export function useBoardActions({
if (backlogFeatures.length === 0) {
toast.info("Backlog empty", {
description: currentWorktreeBranch && currentWorktreeBranch !== "main" && currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
description:
currentWorktreeBranch &&
currentWorktreeBranch !== "main" &&
currentWorktreeBranch !== "master"
? `No features in backlog for branch "${currentWorktreeBranch}".`
: "No features in backlog to start.",
});
return;
}
@@ -833,7 +860,6 @@ export function useBoardActions({
handleOpenFollowUp,
handleSendFollowUp,
handleCommitFeature,
handleRevertFeature,
handleMergeFeature,
handleCompleteFeature,
handleUnarchiveFeature,

View File

@@ -57,22 +57,22 @@ export function useBoardColumnFeatures({
// 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 main only)
// Features with neither are considered unassigned (show on ALL worktrees)
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;
// 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);
} else if (effectiveBranch === null) {
// We're selecting a non-main worktree but can't determine its branch yet
// (worktrees haven't loaded). Don't show branch-only features until we know.
// This prevents showing wrong features during loading.
matchesWorktree = false;
// 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";
} else {
// Has branchName but no worktreePath - match by branch name
matchesWorktree = featureBranch === effectiveBranch;