"use client"; import { useEffect, useState, useCallback, useMemo } from "react"; import { PointerSensor, useSensor, useSensors, rectIntersection, pointerWithin, } 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"; import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; import { useWindowState } from "@/hooks/use-window-state"; // Board-view specific imports import { BoardHeader } from "./board-view/board-header"; import { BoardSearchBar } from "./board-view/board-search-bar"; import { BoardControls } from "./board-view/board-controls"; import { KanbanBoard } from "./board-view/kanban-board"; import { AddFeatureDialog, AgentOutputModal, CompletedFeaturesModal, DeleteAllVerifiedDialog, DeleteCompletedFeatureDialog, EditFeatureDialog, FeatureSuggestionsDialog, FollowUpDialog, PlanApprovalDialog, } from "./board-view/dialogs"; import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog"; import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog"; import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog"; import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog"; import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog"; import { WorktreePanel } from "./board-view/worktree-panel"; import { COLUMNS } from "./board-view/constants"; import { useBoardFeatures, useBoardDragDrop, useBoardActions, useBoardKeyboardShortcuts, useBoardColumnFeatures, useBoardEffects, useBoardBackground, useBoardPersistence, useFollowUpState, useSuggestionsState, } from "./board-view/hooks"; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType< ReturnType["getWorktrees"] > = []; export function BoardView() { const { currentProject, maxConcurrency, setMaxConcurrency, defaultSkipTests, showProfilesOnly, aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, specCreatingForProject, setSpecCreatingForProject, pendingPlanApproval, setPendingPlanApproval, updateFeature, getCurrentWorktree, setCurrentWorktree, getWorktrees, setWorktrees, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, isLoading, persistedCategories, loadFeatures, saveCategory, } = useBoardFeatures({ currentProject }); const [editingFeature, setEditingFeature] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); const [isMounted, setIsMounted] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false); const [outputFeature, setOutputFeature] = useState(null); const [featuresWithContext, setFeaturesWithContext] = useState>( new Set() ); const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = useState(false); const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false); const [showCompletedModal, setShowCompletedModal] = useState(false); const [deleteCompletedFeature, setDeleteCompletedFeature] = useState(null); // State for viewing plan in read-only mode const [viewPlanFeature, setViewPlanFeature] = useState(null); // Worktree dialog states 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<{ path: string; branch: string; isMain: boolean; hasChanges?: boolean; changedFilesCount?: number; } | null>(null); const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0); // Follow-up state hook const { showFollowUpDialog, followUpFeature, followUpPrompt, followUpImagePaths, followUpPreviewMap, setShowFollowUpDialog, setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setFollowUpPreviewMap, handleFollowUpDialogChange, } = useFollowUpState(); // Suggestions state hook const { showSuggestionsDialog, suggestionsCount, featureSuggestions, isGeneratingSuggestions, setShowSuggestionsDialog, setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions, updateSuggestions, closeSuggestionsDialog, } = useSuggestionsState(); // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(""); // Plan approval loading state const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; const creatingSpecProjectPath = specCreatingForProject ?? undefined; const checkContextExists = useCallback( async (featureId: string): Promise => { if (!currentProject) return false; try { const api = getElectronAPI(); if (!api?.autoMode?.contextExists) { return false; } const result = await api.autoMode.contextExists( currentProject.path, featureId ); return result.success && result.exists === true; } catch (error) { console.error("[Board] Error checking context:", error); return false; } }, [currentProject] ); // Use board effects hook useBoardEffects({ currentProject, specCreatingForProject, setSpecCreatingForProject, setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions, checkContextExists, features: hookFeatures, isLoading, setFeaturesWithContext, }); // Auto mode hook const autoMode = useAutoMode(); // Get runningTasks from the hook (scoped to current project) const runningAutoTasks = autoMode.runningTasks; // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); // Keyboard shortcuts hook will be initialized after actions hook // Prevent hydration issues useEffect(() => { setIsMounted(true); }, []); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }) ); // Get unique categories from existing features AND persisted categories for autocomplete suggestions const categorySuggestions = useMemo(() => { const featureCategories = hookFeatures .map((f) => f.category) .filter(Boolean); // Merge feature categories with persisted categories const allCategories = [...featureCategories, ...persistedCategories]; return [...new Set(allCategories)].sort(); }, [hookFeatures, persistedCategories]); // Branch suggestions for the branch autocomplete // Shows all local branches as suggestions, but users can type any new branch name // When the feature is started, a worktree will be created if needed const [branchSuggestions, setBranchSuggestions] = useState([]); // Fetch branches when project changes or worktrees are created/modified 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, 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) ); // If we found a column collision, use that if (columnCollisions.length > 0) { return columnCollisions; } // Otherwise, use rectangle intersection for cards return rectIntersection(args); }, []); // Use persistence hook const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({ currentProject }); // Get in-progress features for keyboard shortcuts (needed before actions hook) const inProgressFeaturesForShortcuts = useMemo(() => { return hookFeatures.filter((f) => { const isRunning = runningAutoTasks.includes(f.id); return isRunning || f.status === "in_progress"; }); }, [hookFeatures, runningAutoTasks]); // 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 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] ); // 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"; // Extract all action handlers into a hook const { handleAddFeature, handleUpdateFeature, handleDeleteFeature, handleStartImplementation, handleVerifyFeature, handleResumeFeature, handleManualVerify, handleMoveBackToInProgress, handleOpenFollowUp, handleSendFollowUp, handleCommitFeature, handleMergeFeature, handleCompleteFeature, handleUnarchiveFeature, handleViewOutput, handleOutputModalNumberKeyPress, handleForceStopFeature, handleStartNextFeatures, handleDeleteAllVerified, } = useBoardActions({ currentProject, features: hookFeatures, runningAutoTasks, loadFeatures, persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete, saveCategory, setEditingFeature, setShowOutputModal, setOutputFeature, followUpFeature, followUpPrompt, followUpImagePaths, setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setFollowUpPreviewMap, setShowFollowUpDialog, inProgressFeaturesForShortcuts, outputFeature, projectPath: currentProject?.path || null, onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), currentWorktreeBranch, }); // Use keyboard shortcuts hook (after actions hook) useBoardKeyboardShortcuts({ features: hookFeatures, runningAutoTasks, onAddFeature: () => setShowAddDialog(true), onStartNextFeatures: handleStartNextFeatures, onViewOutput: handleViewOutput, }); // Use drag and drop hook const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({ features: hookFeatures, currentProject, runningAutoTasks, persistFeatureUpdate, handleStartImplementation, projectPath: currentProject?.path || null, onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), }); // Use column features hook const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({ features: hookFeatures, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath: currentProject?.path || null, }); // Use background hook const { backgroundSettings, backgroundImageStyle } = useBoardBackground({ currentProject, }); // Find feature for pending plan approval const pendingApprovalFeature = useMemo(() => { if (!pendingPlanApproval) return null; return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; }, [pendingPlanApproval, hookFeatures]); // Handle plan approval const handlePlanApprove = useCallback( async (editedPlan?: string) => { if (!pendingPlanApproval || !currentProject) return; const featureId = pendingPlanApproval.featureId; setIsPlanApprovalLoading(true); try { const api = getElectronAPI(); if (!api?.autoMode?.approvePlan) { throw new Error("Plan approval API not available"); } const result = await api.autoMode.approvePlan( pendingPlanApproval.projectPath, pendingPlanApproval.featureId, true, editedPlan ); 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); updateFeature(featureId, { planSpec: { status: 'approved', content: editedPlan || pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, approvedAt: new Date().toISOString(), reviewedByUser: true, }, }); // Reload features from server to ensure sync loadFeatures(); } else { console.error("[Board] Failed to approve plan:", result.error); } } catch (error) { console.error("[Board] Error approving plan:", error); } finally { setIsPlanApprovalLoading(false); setPendingPlanApproval(null); } }, [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] ); // Handle plan rejection const handlePlanReject = useCallback( async (feedback?: string) => { if (!pendingPlanApproval || !currentProject) return; const featureId = pendingPlanApproval.featureId; setIsPlanApprovalLoading(true); try { const api = getElectronAPI(); if (!api?.autoMode?.approvePlan) { throw new Error("Plan approval API not available"); } const result = await api.autoMode.approvePlan( pendingPlanApproval.projectPath, pendingPlanApproval.featureId, false, undefined, feedback ); if (result.success) { // Immediately update local feature state // Get current feature to preserve version const currentFeature = hookFeatures.find(f => f.id === featureId); updateFeature(featureId, { status: 'backlog', planSpec: { status: 'rejected', content: pendingPlanApproval.planContent, version: currentFeature?.planSpec?.version || 1, reviewedByUser: true, }, }); // Reload features from server to ensure sync loadFeatures(); } else { console.error("[Board] Failed to reject plan:", result.error); } } catch (error) { console.error("[Board] Error rejecting plan:", error); } finally { setIsPlanApprovalLoading(false); setPendingPlanApproval(null); } }, [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] ); // Handle opening approval dialog from feature card button const handleOpenApprovalDialog = useCallback( (feature: Feature) => { if (!feature.planSpec?.content || !currentProject) return; // 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'; // Re-open the approval dialog with the feature's plan data setPendingPlanApproval({ featureId: feature.id, projectPath: currentProject.path, planContent: feature.planSpec.content, planningMode: approvalMode, }); }, [currentProject, setPendingPlanApproval] ); if (!currentProject) { return (

No project selected

); } if (isLoading) { return (
); } return (
{/* Header */} autoMode.start()} onStopAutoMode={() => autoMode.stop()} onAddFeature={() => setShowAddDialog(true)} addFeatureShortcut={{ key: shortcuts.addFeature, action: () => setShowAddDialog(true), description: "Add new feature", }} isMounted={isMounted} /> {/* Worktree Panel */} setShowCreateWorktreeDialog(true)} onDeleteWorktree={(worktree) => { setSelectedWorktreeForAction(worktree); setShowDeleteWorktreeDialog(true); }} onCommit={(worktree) => { setSelectedWorktreeForAction(worktree); setShowCommitWorktreeDialog(true); }} onCreatePR={(worktree) => { setSelectedWorktreeForAction(worktree); setShowCreatePRDialog(true); }} onCreateBranch={(worktree) => { setSelectedWorktreeForAction(worktree); setShowCreateBranchDialog(true); }} runningFeatureIds={runningAutoTasks} features={hookFeatures.map((f) => ({ id: f.id, worktreePath: f.worktreePath, branchName: f.branchName, }))} /> {/* Main Content Area */}
{/* Search Bar Row */}
{/* Board Background & Detail Level Controls */} setShowBoardBackgroundModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)} completedCount={completedFeatures.length} kanbanCardDetailLevel={kanbanCardDetailLevel} onDetailLevelChange={setKanbanCardDetailLevel} />
{/* Kanban Columns */} setEditingFeature(feature)} onDelete={(featureId) => handleDeleteFeature(featureId)} onViewOutput={handleViewOutput} onVerify={handleVerifyFeature} onResume={handleResumeFeature} onForceStop={handleForceStopFeature} onManualVerify={handleManualVerify} onMoveBackToInProgress={handleMoveBackToInProgress} onFollowUp={handleOpenFollowUp} onCommit={handleCommitFeature} onComplete={handleCompleteFeature} onImplement={handleStartImplementation} onViewPlan={(feature) => setViewPlanFeature(feature)} onApprovePlan={handleOpenApprovalDialog} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} shortcuts={shortcuts} onStartNextFeatures={handleStartNextFeatures} onShowSuggestions={() => setShowSuggestionsDialog(true)} suggestionsCount={suggestionsCount} onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)} />
{/* Board Background Modal */} {/* Completed Features Modal */} setDeleteCompletedFeature(feature)} /> {/* Delete Completed Feature Confirmation Dialog */} setDeleteCompletedFeature(null)} onConfirm={async () => { if (deleteCompletedFeature) { await handleDeleteFeature(deleteCompletedFeature.id); setDeleteCompletedFeature(null); } }} /> {/* Add Feature Dialog */} {/* Edit Feature Dialog */} setEditingFeature(null)} onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} allFeatures={hookFeatures} /> {/* Agent Output Modal */} setShowOutputModal(false)} featureDescription={outputFeature?.description || ""} featureId={outputFeature?.id || ""} featureStatus={outputFeature?.status} onNumberKeyPress={handleOutputModalNumberKeyPress} /> {/* Delete All Verified Dialog */} { await handleDeleteAllVerified(); setShowDeleteAllVerifiedDialog(false); }} /> {/* Follow-Up Prompt Dialog */} {/* Feature Suggestions Dialog */} {/* Plan Approval Dialog */} { if (!open) { setPendingPlanApproval(null); } }} feature={pendingApprovalFeature} planContent={pendingPlanApproval?.planContent || ""} onApprove={handlePlanApprove} onReject={handlePlanReject} isLoading={isPlanApprovalLoading} /> {/* View Plan Dialog (read-only) */} {viewPlanFeature && viewPlanFeature.planSpec?.content && ( !open && setViewPlanFeature(null)} feature={viewPlanFeature} planContent={viewPlanFeature.planSpec.content} onApprove={() => setViewPlanFeature(null)} onReject={() => setViewPlanFeature(null)} viewOnly={true} /> )} {/* Create Worktree Dialog */} { // Add the new worktree to the store immediately to avoid race condition // when deriving currentWorktreeBranch for filtering const currentWorktrees = getWorktrees(currentProject.path); const newWorktreeInfo = { path: newWorktree.path, branch: newWorktree.branch, isMain: false, isCurrent: false, hasWorktree: true, }; setWorktrees(currentProject.path, [ ...currentWorktrees, newWorktreeInfo, ]); // Now set the current worktree with both path and branch setCurrentWorktree( currentProject.path, newWorktree.path, newWorktree.branch ); // Trigger refresh to get full worktree details (hasChanges, etc.) setWorktreeRefreshKey((k) => k + 1); }} /> {/* Delete Worktree Dialog */} { // 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); }} /> {/* Commit Worktree Dialog */} { setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} /> {/* Create PR Dialog */} { setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} /> {/* Create Branch Dialog */} { setWorktreeRefreshKey((k) => k + 1); setSelectedWorktreeForAction(null); }} />
); }