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"; import { toast } from "sonner"; import { useAutoMode } from "@/hooks/use-auto-mode"; import { truncateDescription } from "@/lib/utils"; import { getBlockingDependencies } from "@/lib/dependency-resolver"; interface UseBoardActionsProps { currentProject: { path: string; id: string } | null; features: Feature[]; runningAutoTasks: string[]; loadFeatures: () => Promise; persistFeatureCreate: (feature: Feature) => Promise; persistFeatureUpdate: ( featureId: string, updates: Partial ) => Promise; persistFeatureDelete: (featureId: string) => Promise; saveCategory: (category: string) => Promise; setEditingFeature: (feature: Feature | null) => void; setShowOutputModal: (show: boolean) => void; setOutputFeature: (feature: Feature | null) => void; followUpFeature: Feature | null; followUpPrompt: string; followUpImagePaths: DescriptionImagePath[]; setFollowUpFeature: (feature: Feature | null) => void; setFollowUpPrompt: (prompt: string) => void; setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void; setFollowUpPreviewMap: (map: Map) => void; setShowFollowUpDialog: (show: boolean) => void; inProgressFeaturesForShortcuts: Feature[]; outputFeature: Feature | null; projectPath: string | null; onWorktreeCreated?: () => void; currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering } export function useBoardActions({ currentProject, features, runningAutoTasks, loadFeatures, persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete, saveCategory, setEditingFeature, setShowOutputModal, setOutputFeature, followUpFeature, followUpPrompt, followUpImagePaths, setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setFollowUpPreviewMap, setShowFollowUpDialog, inProgressFeaturesForShortcuts, outputFeature, projectPath, onWorktreeCreated, currentWorktreeBranch, }: UseBoardActionsProps) { const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees, enableDependencyBlocking, } = 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 => { 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( async (featureData: { category: string; description: string; steps: string[]; images: FeatureImage[]; imagePaths: DescriptionImagePath[]; skipTests: boolean; model: AgentModel; thinkingLevel: ThinkingLevel; 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); // Must await to ensure feature exists on server before user can drag it await persistFeatureCreate(createdFeature); saveCategory(featureData.category); }, [addFeature, persistFeatureCreate, saveCategory, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] ); const handleUpdateFeature = useCallback( async ( featureId: string, updates: { category: string; description: string; steps: string[]; skipTests: boolean; model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; branchName: string; priority: number; } ) => { // 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, features, useWorktrees, getOrCreateWorktreeForFeature, projectPath, onWorktreeCreated] ); const handleDeleteFeature = useCallback( async (featureId: string) => { const feature = features.find((f) => f.id === featureId); if (!feature) return; const isRunning = runningAutoTasks.includes(featureId); if (isRunning) { try { await autoMode.stopFeature(featureId); toast.success("Agent stopped", { description: `Stopped and deleted: ${truncateDescription( feature.description )}`, }); } catch (error) { console.error("[Board] Error stopping feature before delete:", error); toast.error("Failed to stop agent", { description: "The feature will still be deleted.", }); } } if (feature.imagePaths && feature.imagePaths.length > 0) { try { const api = getElectronAPI(); for (const imagePathObj of feature.imagePaths) { try { await api.deleteFile(imagePathObj.path); console.log(`[Board] Deleted image: ${imagePathObj.path}`); } catch (error) { console.error( `[Board] Failed to delete image ${imagePathObj.path}:`, error ); } } } catch (error) { console.error( `[Board] Error deleting images for feature ${featureId}:`, error ); } } removeFeature(featureId); persistFeatureDelete(featureId); }, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] ); const handleRunFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); 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, featureWorktreePath || undefined ); if (result.success) { console.log( "[Board] Feature run started successfully in worktree:", featureWorktreePath || "main" ); } else { console.error("[Board] Failed to run feature:", result.error); await loadFeatures(); } } catch (error) { console.error("[Board] Error running feature:", error); await loadFeatures(); } }, [currentProject, useWorktrees, loadFeatures] ); const handleStartImplementation = useCallback( async (feature: Feature) => { if (!autoMode.canStartNewTask) { toast.error("Concurrency limit reached", { description: `You can only have ${autoMode.maxConcurrency} task${ autoMode.maxConcurrency > 1 ? "s" : "" } running at a time. Wait for a task to complete or increase the limit.`, }); return false; } // Check for blocking dependencies and show warning if enabled if (enableDependencyBlocking) { const blockingDeps = getBlockingDependencies(feature, features); if (blockingDeps.length > 0) { const depDescriptions = blockingDeps.map(depId => { const dep = features.find(f => f.id === depId); return dep ? truncateDescription(dep.description, 40) : depId; }).join(", "); toast.warning("Starting feature with incomplete dependencies", { description: `This feature depends on: ${depDescriptions}`, }); } } const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), }; updateFeature(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; }, [autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature] ); const handleVerifyFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); return; } const result = await api.autoMode.verifyFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature verification started successfully"); } else { console.error("[Board] Failed to verify feature:", result.error); await loadFeatures(); } } catch (error) { console.error("[Board] Error verifying feature:", error); await loadFeatures(); } }, [currentProject, loadFeatures] ); const handleResumeFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); return; } const result = await api.autoMode.resumeFeature( currentProject.path, feature.id, useWorktrees ); if (result.success) { console.log("[Board] Feature resume started successfully"); } else { console.error("[Board] Failed to resume feature:", result.error); await loadFeatures(); } } catch (error) { console.error("[Board] Error resuming feature:", error); await loadFeatures(); } }, [currentProject, loadFeatures, useWorktrees] ); const handleManualVerify = useCallback( (feature: Feature) => { moveFeature(feature.id, "verified"); persistFeatureUpdate(feature.id, { status: "verified", justFinishedAt: undefined, }); toast.success("Feature verified", { description: `Marked as verified: ${truncateDescription( feature.description )}`, }); }, [moveFeature, persistFeatureUpdate] ); const handleMoveBackToInProgress = useCallback( (feature: Feature) => { const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); toast.info("Feature moved back", { description: `Moved back to In Progress: ${truncateDescription( feature.description )}`, }); }, [updateFeature, persistFeatureUpdate] ); const handleOpenFollowUp = useCallback( (feature: Feature) => { setFollowUpFeature(feature); setFollowUpPrompt(""); setFollowUpImagePaths([]); setShowFollowUpDialog(true); }, [ setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog, ] ); const handleSendFollowUp = useCallback(async () => { if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return; const featureId = followUpFeature.id; const featureDescription = followUpFeature.description; const prompt = followUpPrompt; const api = getElectronAPI(); if (!api?.autoMode?.followUpFeature) { console.error("Follow-up feature API not available"); toast.error("Follow-up not available", { description: "This feature is not available in the current version.", }); return; } const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); setShowFollowUpDialog(false); setFollowUpFeature(null); setFollowUpPrompt(""); setFollowUpImagePaths([]); setFollowUpPreviewMap(new Map()); toast.success("Follow-up started", { description: `Continuing work on: ${truncateDescription( featureDescription )}`, }); 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, featureWorktreePath ) .catch((error) => { console.error("[Board] Error sending follow-up:", error); toast.error("Failed to send follow-up", { description: error instanceof Error ? error.message : "An error occurred", }); loadFeatures(); }); }, [ currentProject, followUpFeature, followUpPrompt, followUpImagePaths, updateFeature, persistFeatureUpdate, setShowFollowUpDialog, setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setFollowUpPreviewMap, loadFeatures, ]); const handleCommitFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.autoMode?.commitFeature) { console.error("Commit feature API not available"); toast.error("Commit not available", { description: "This feature is not available in the current version.", }); return; } // 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"); persistFeatureUpdate(feature.id, { status: "verified" }); 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", { description: result.error || "An error occurred", }); await loadFeatures(); } } catch (error) { console.error("[Board] Error committing feature:", error); toast.error("Failed to commit feature", { description: error instanceof Error ? error.message : "An error occurred", }); await loadFeatures(); } }, [ currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated, ] ); const handleMergeFeature = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.worktree?.mergeFeature) { console.error("Worktree API not available"); toast.error("Merge not available", { description: "This feature is not available in the current version.", }); return; } const result = await api.worktree.mergeFeature( currentProject.path, feature.id ); if (result.success) { await loadFeatures(); toast.success("Feature merged", { description: `Changes merged to main branch: ${truncateDescription( feature.description )}`, }); } else { console.error("[Board] Failed to merge feature:", result.error); toast.error("Failed to merge feature", { description: result.error || "An error occurred", }); } } catch (error) { console.error("[Board] Error merging feature:", error); toast.error("Failed to merge feature", { description: error instanceof Error ? error.message : "An error occurred", }); } }, [currentProject, loadFeatures] ); const handleCompleteFeature = useCallback( (feature: Feature) => { const updates = { status: "completed" as const, }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); toast.success("Feature completed", { description: `Archived: ${truncateDescription(feature.description)}`, }); }, [updateFeature, persistFeatureUpdate] ); const handleUnarchiveFeature = useCallback( (feature: Feature) => { const updates = { status: "verified" as const, }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); toast.success("Feature restored", { description: `Moved back to verified: ${truncateDescription( feature.description )}`, }); }, [updateFeature, persistFeatureUpdate] ); const handleViewOutput = useCallback( (feature: Feature) => { setOutputFeature(feature); setShowOutputModal(true); }, [setOutputFeature, setShowOutputModal] ); const handleOutputModalNumberKeyPress = useCallback( (key: string) => { const index = key === "0" ? 9 : parseInt(key, 10) - 1; const targetFeature = inProgressFeaturesForShortcuts[index]; if (!targetFeature) { return; } if (targetFeature.id === outputFeature?.id) { setShowOutputModal(false); } else { setOutputFeature(targetFeature); } }, [ inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature, ] ); const handleForceStopFeature = useCallback( async (feature: Feature) => { try { await autoMode.stopFeature(feature.id); const targetStatus = feature.skipTests && feature.status === "waiting_approval" ? "waiting_approval" : "backlog"; if (targetStatus !== feature.status) { moveFeature(feature.id, targetStatus); // Must await to ensure file is written before user can restart await persistFeatureUpdate(feature.id, { status: targetStatus }); } toast.success("Agent stopped", { description: targetStatus === "waiting_approval" ? `Stopped commit - returned to waiting approval: ${truncateDescription( feature.description )}` : `Stopped working on: ${truncateDescription( feature.description )}`, }); } catch (error) { console.error("[Board] Error stopping feature:", error); toast.error("Failed to stop agent", { description: error instanceof Error ? error.message : "An error occurred", }); } }, [autoMode, moveFeature, persistFeatureUpdate] ); const handleStartNextFeatures = useCallback(async () => { // Filter backlog features by the currently selected worktree branch // This ensures "G" only starts features from the filtered list const backlogFeatures = features.filter((f) => { if (f.status !== "backlog") return false; // Determine the feature's branch (default to "main" if not set) const featureBranch = f.branchName || "main"; // 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" ); } // Otherwise, only show features matching the selected worktree branch return featureBranch === currentWorktreeBranch; }); const availableSlots = useAppStore.getState().maxConcurrency - runningAutoTasks.length; if (availableSlots <= 0) { toast.error("Concurrency limit reached", { description: "Wait for a task to complete or increase the concurrency limit.", }); return; } 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.", }); return; } // Sort by priority (lower number = higher priority, priority 1 is highest) // This matches the auto mode service behavior for consistency const sortedBacklog = [...backlogFeatures].sort( (a, b) => (a.priority || 999) - (b.priority || 999) ); // Start only one feature per keypress (user must press again for next) const featuresToStart = sortedBacklog.slice(0, 1); for (const feature of featuresToStart) { // Only create worktrees if the feature is enabled let worktreePath: string | null = null; if (useWorktrees) { // Get or create worktree based on the feature's assigned branch (same as drag-to-in-progress) worktreePath = await getOrCreateWorktreeForFeature(feature); if (worktreePath) { await persistFeatureUpdate(feature.id, { worktreePath }); } // Refresh worktree selector after creating worktree onWorktreeCreated?.(); } // Start the implementation // Pass feature with worktreePath so handleRunFeature uses the correct path await handleStartImplementation({ ...feature, worktreePath: worktreePath || undefined, }); } }, [ features, runningAutoTasks, handleStartImplementation, getOrCreateWorktreeForFeature, persistFeatureUpdate, onWorktreeCreated, currentWorktreeBranch, useWorktrees, ]); const handleDeleteAllVerified = useCallback(async () => { const verifiedFeatures = features.filter((f) => f.status === "verified"); for (const feature of verifiedFeatures) { const isRunning = runningAutoTasks.includes(feature.id); if (isRunning) { try { await autoMode.stopFeature(feature.id); } catch (error) { console.error("[Board] Error stopping feature before delete:", error); } } removeFeature(feature.id); persistFeatureDelete(feature.id); } toast.success("All verified features deleted", { description: `Deleted ${verifiedFeatures.length} feature(s).`, }); }, [ features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, ]); return { handleAddFeature, handleUpdateFeature, handleDeleteFeature, handleStartImplementation, handleVerifyFeature, handleResumeFeature, handleManualVerify, handleMoveBackToInProgress, handleOpenFollowUp, handleSendFollowUp, handleCommitFeature, handleMergeFeature, handleCompleteFeature, handleUnarchiveFeature, handleViewOutput, handleOutputModalNumberKeyPress, handleForceStopFeature, handleStartNextFeatures, handleDeleteAllVerified, }; }