From 18fa0f3066f486b002b1cf34c8bd84907e1d689f Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Thu, 18 Dec 2025 19:24:19 -0500 Subject: [PATCH] refactor: streamline session and board view components - Consolidated imports in session-manager.tsx for cleaner code. - Improved state initialization formatting for better readability. - Updated board-view.tsx to enhance feature management, including the use of refs to track running tasks and prevent unnecessary effect re-runs. - Added affectedFeatureCount prop to DeleteWorktreeDialog for better user feedback on feature assignments. - Refactored useBoardActions to ensure worktrees are created when features are added or updated, improving overall workflow efficiency. --- apps/app/src/components/session-manager.tsx | 19 ++-- apps/app/src/components/views/board-view.tsx | 86 ++++++++++++++----- .../dialogs/delete-worktree-dialog.tsx | 17 +++- .../board-view/hooks/use-board-actions.ts | 85 ++++++++++++++++-- apps/server/src/services/auto-mode-service.ts | 77 +++-------------- 5 files changed, 175 insertions(+), 109 deletions(-) diff --git a/apps/app/src/components/session-manager.tsx b/apps/app/src/components/session-manager.tsx index ce8b95a4..aa59a055 100644 --- a/apps/app/src/components/session-manager.tsx +++ b/apps/app/src/components/session-manager.tsx @@ -1,12 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Input } from "@/components/ui/input"; @@ -116,8 +111,10 @@ export function SessionManager({ new Set() ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); - const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false); + const [sessionToDelete, setSessionToDelete] = + useState(null); + const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = + useState(false); // Check running state for all sessions const checkRunningSessions = async (sessionList: SessionListItem[]) => { @@ -234,11 +231,7 @@ export function SessionManager({ const api = getElectronAPI(); if (!editingName.trim() || !api?.sessions) return; - const result = await api.sessions.update( - sessionId, - editingName, - undefined - ); + const result = await api.sessions.update(sessionId, editingName, undefined); if (result.success) { setEditingSessionId(null); diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index b1b64247..c83c7b8d 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -313,14 +313,14 @@ export function BoardView() { }); if (matchesRemovedWorktree) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { - branchName: null as unknown as string | undefined, - }); + // Reset the feature's branch assignment - update both local state and persist + const updates = { branchName: null as unknown as string | undefined }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } }); }, - [hookFeatures, persistFeatureUpdate] + [hookFeatures, updateFeature, persistFeatureUpdate] ); // Get in-progress features for keyboard shortcuts (needed before actions hook) @@ -429,6 +429,18 @@ export function BoardView() { hookFeaturesRef.current = hookFeatures; }, [hookFeatures]); + // Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef + const runningAutoTasksRef = useRef(runningAutoTasks); + useEffect(() => { + runningAutoTasksRef.current = runningAutoTasks; + }, [runningAutoTasks]); + + // Keep latest start handler without retriggering the auto mode effect + const handleStartImplementationRef = useRef(handleStartImplementation); + useEffect(() => { + handleStartImplementationRef.current = handleStartImplementation; + }, [handleStartImplementation]); + // Track features that are pending (started but not yet confirmed running) const pendingFeaturesRef = useRef>(new Set()); @@ -496,8 +508,9 @@ export function BoardView() { } // Count currently running tasks + pending features + // Use ref to get the latest running tasks without causing effect re-runs const currentRunning = - runningAutoTasks.length + pendingFeaturesRef.current.size; + runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; const availableSlots = maxConcurrency - currentRunning; // No available slots, skip check @@ -552,6 +565,10 @@ export function BoardView() { // Start features up to available slots const featuresToStart = eligibleFeatures.slice(0, availableSlots); + const startImplementation = handleStartImplementationRef.current; + if (!startImplementation) { + return; + } for (const feature of featuresToStart) { // Check again before starting each feature @@ -577,7 +594,7 @@ export function BoardView() { } // Start the implementation - server will derive workDir from feature.branchName - const started = await handleStartImplementation(feature); + const started = await startImplementation(feature); // If successfully started, track it as pending until we receive the start event if (started) { @@ -591,7 +608,7 @@ export function BoardView() { // Check immediately, then every 3 seconds checkAndStartFeatures(); - const interval = setInterval(checkAndStartFeatures, 3000); + const interval = setInterval(checkAndStartFeatures, 1000); return () => { // Mark as inactive to prevent any pending async operations from continuing @@ -603,7 +620,8 @@ export function BoardView() { }, [ autoMode.isRunning, currentProject, - runningAutoTasks, + // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs + // that would clear pendingFeaturesRef and cause concurrency issues maxConcurrency, // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs currentWorktreeBranch, @@ -612,7 +630,6 @@ export function BoardView() { isPrimaryWorktreeBranch, enableDependencyBlocking, persistFeatureUpdate, - handleStartImplementation, ]); // Use keyboard shortcuts hook (after actions hook) @@ -651,7 +668,9 @@ export function BoardView() { // Find feature for pending plan approval const pendingApprovalFeature = useMemo(() => { if (!pendingPlanApproval) return null; - return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; + return ( + hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null + ); }, [pendingPlanApproval, hookFeatures]); // Handle plan approval @@ -677,10 +696,10 @@ export function BoardView() { if (result.success) { // Immediately update local feature state to hide "Approve Plan" button // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { planSpec: { - status: 'approved', + status: "approved", content: editedPlan || pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, approvedAt: new Date().toISOString(), @@ -699,7 +718,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle plan rejection @@ -726,11 +752,11 @@ export function BoardView() { if (result.success) { // Immediately update local feature state // Get current feature to preserve version - const currentFeature = hookFeatures.find(f => f.id === featureId); + const currentFeature = hookFeatures.find((f) => f.id === featureId); updateFeature(featureId, { - status: 'backlog', + status: "backlog", planSpec: { - status: 'rejected', + status: "rejected", content: pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, reviewedByUser: true, @@ -748,7 +774,14 @@ export function BoardView() { setPendingPlanApproval(null); } }, - [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + [ + pendingPlanApproval, + currentProject, + setPendingPlanApproval, + updateFeature, + loadFeatures, + hookFeatures, + ] ); // Handle opening approval dialog from feature card button @@ -759,7 +792,7 @@ export function BoardView() { // Determine the planning mode for approval (skip should never have a plan requiring approval) const mode = feature.planningMode; const approvalMode: "lite" | "spec" | "full" = - mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec'; + mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec"; // Re-open the approval dialog with the feature's plan data setPendingPlanApproval({ @@ -1079,15 +1112,24 @@ export function BoardView() { onOpenChange={setShowDeleteWorktreeDialog} projectPath={currentProject.path} worktree={selectedWorktreeForAction} + affectedFeatureCount={ + selectedWorktreeForAction + ? hookFeatures.filter( + (f) => f.branchName === selectedWorktreeForAction.branch + ).length + : 0 + } onDeleted={(deletedWorktree, _deletedBranch) => { // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { // Match by branch name since worktreePath is no longer stored if (feature.branchName === deletedWorktree.branch) { - // Reset the feature's branch assignment - persistFeatureUpdate(feature.id, { + // Reset the feature's branch assignment - update both local state and persist + const updates = { branchName: null as unknown as string | undefined, - }); + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); } }); diff --git a/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index 0e228ab2..203de819 100644 --- a/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -12,7 +12,7 @@ import { import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; -import { Loader2, Trash2, AlertTriangle } from "lucide-react"; +import { Loader2, Trash2, AlertTriangle, FileWarning } from "lucide-react"; import { getElectronAPI } from "@/lib/electron"; import { toast } from "sonner"; @@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps { projectPath: string; worktree: WorktreeInfo | null; onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; + /** Number of features assigned to this worktree's branch */ + affectedFeatureCount?: number; } export function DeleteWorktreeDialog({ @@ -38,6 +40,7 @@ export function DeleteWorktreeDialog({ projectPath, worktree, onDeleted, + affectedFeatureCount = 0, }: DeleteWorktreeDialogProps) { const [deleteBranch, setDeleteBranch] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -100,6 +103,18 @@ export function DeleteWorktreeDialog({ ? + {affectedFeatureCount > 0 && ( +
+ + + {affectedFeatureCount} feature{affectedFeatureCount !== 1 ? "s" : ""}{" "} + {affectedFeatureCount !== 1 ? "are" : "is"} assigned to this + branch. {affectedFeatureCount !== 1 ? "They" : "It"} will be + unassigned and moved to the main worktree. + +
+ )} + {worktree.hasChanges && (
diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts index 9deb8a40..8370d96f 100644 --- a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts @@ -82,8 +82,8 @@ export function useBoardActions({ } = useAppStore(); const autoMode = useAutoMode(); - // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side - // at execution time based on feature.branchName + // Worktrees are created when adding/editing features with a branch name + // This ensures the worktree exists before the feature starts execution const handleAddFeature = useCallback( async (featureData: { @@ -100,24 +100,58 @@ export function useBoardActions({ planningMode: PlanningMode; requirePlanApproval: boolean; }) => { - // Simplified: Only store branchName, no worktree creation on add - // Worktrees are created at execution time (when feature starts) // Empty string means "unassigned" (show only on primary worktree) - convert to undefined // Non-empty string is the actual branch name (for non-primary worktrees) const finalBranchName = featureData.branchName || undefined; + // If worktrees enabled and a branch is specified, create the worktree now + // This ensures the worktree exists before the feature starts + if (useWorktrees && finalBranchName && currentProject) { + try { + const api = getElectronAPI(); + if (api?.worktree?.create) { + const result = await api.worktree.create( + currentProject.path, + finalBranchName + ); + if (result.success) { + console.log( + `[Board] Worktree for branch "${finalBranchName}" ${ + result.worktree?.isNew ? "created" : "already exists" + }` + ); + // Refresh worktree list in UI + onWorktreeCreated?.(); + } else { + console.error( + `[Board] Failed to create worktree for branch "${finalBranchName}":`, + result.error + ); + toast.error("Failed to create worktree", { + description: result.error || "An error occurred", + }); + } + } + } catch (error) { + console.error("[Board] Error creating worktree:", error); + toast.error("Failed to create worktree", { + description: + error instanceof Error ? error.message : "An error occurred", + }); + } + } + const newFeatureData = { ...featureData, status: "backlog" as const, branchName: finalBranchName, - // No worktreePath - derived at runtime from branchName }; const createdFeature = addFeature(newFeatureData); // Must await to ensure feature exists on server before user can drag it await persistFeatureCreate(createdFeature); saveCategory(featureData.category); }, - [addFeature, persistFeatureCreate, saveCategory] + [addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated] ); const handleUpdateFeature = useCallback( @@ -139,6 +173,43 @@ export function useBoardActions({ ) => { const finalBranchName = updates.branchName || undefined; + // If worktrees enabled and a branch is specified, create the worktree now + // This ensures the worktree exists before the feature starts + if (useWorktrees && finalBranchName && currentProject) { + try { + const api = getElectronAPI(); + if (api?.worktree?.create) { + const result = await api.worktree.create( + currentProject.path, + finalBranchName + ); + if (result.success) { + console.log( + `[Board] Worktree for branch "${finalBranchName}" ${ + result.worktree?.isNew ? "created" : "already exists" + }` + ); + // Refresh worktree list in UI + onWorktreeCreated?.(); + } else { + console.error( + `[Board] Failed to create worktree for branch "${finalBranchName}":`, + result.error + ); + toast.error("Failed to create worktree", { + description: result.error || "An error occurred", + }); + } + } + } catch (error) { + console.error("[Board] Error creating worktree:", error); + toast.error("Failed to create worktree", { + description: + error instanceof Error ? error.message : "An error occurred", + }); + } + } + const finalUpdates = { ...updates, branchName: finalBranchName, @@ -151,7 +222,7 @@ export function useBoardActions({ } setEditingFeature(null); }, - [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated] ); const handleDeleteFeature = useCallback( diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index b676c0c9..ab6d99f8 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -520,29 +520,28 @@ export class AutoModeService { } // Derive workDir from feature.branchName - // If no branchName, derive from feature ID: feature/{featureId} + // Worktrees should already be created when the feature is added/edited let worktreePath: string | null = null; - const branchName = feature.branchName || `feature/${featureId}`; + const branchName = feature.branchName; if (useWorktrees && branchName) { // Try to find existing worktree for this branch + // Worktree should already exist (created when feature was added/edited) worktreePath = await this.findExistingWorktreeForBranch( projectPath, branchName ); - if (!worktreePath) { - // Create worktree for this branch - worktreePath = await this.setupWorktree( - projectPath, - featureId, - branchName + if (worktreePath) { + console.log( + `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` + ); + } else { + // Worktree doesn't exist - log warning and continue with project path + console.warn( + `[AutoMode] Worktree for branch "${branchName}" not found, using project path` ); } - - console.log( - `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` - ); } // Ensure workDir is always an absolute path for cross-platform compatibility @@ -1410,60 +1409,6 @@ Format your response as a structured markdown document.`; } } - private async setupWorktree( - projectPath: string, - featureId: string, - branchName: string - ): Promise { - // First, check if git already has a worktree for this branch (anywhere) - const existingWorktree = await this.findExistingWorktreeForBranch( - projectPath, - branchName - ); - if (existingWorktree) { - // Path is already resolved to absolute in findExistingWorktreeForBranch - console.log( - `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}` - ); - return existingWorktree; - } - - // Git worktrees stay in project directory - const worktreesDir = path.join(projectPath, ".worktrees"); - const worktreePath = path.join(worktreesDir, featureId); - - await fs.mkdir(worktreesDir, { recursive: true }); - - // Check if worktree directory already exists (might not be linked to branch) - try { - await fs.access(worktreePath); - // Return absolute path for cross-platform compatibility - return path.resolve(worktreePath); - } catch { - // Create new worktree - } - - // Create branch if it doesn't exist - try { - await execAsync(`git branch ${branchName}`, { cwd: projectPath }); - } catch { - // Branch may already exist - } - - // Create worktree - try { - await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { - cwd: projectPath, - }); - // Return absolute path for cross-platform compatibility - return path.resolve(worktreePath); - } catch (error) { - // Worktree creation failed, fall back to direct execution - console.error(`[AutoMode] Worktree creation failed:`, error); - return path.resolve(projectPath); - } - } - private async loadFeature( projectPath: string, featureId: string