diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 30cd4db31..86936f318 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1,1607 +1,102 @@ -// @ts-nocheck -import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { - PointerSensor, - useSensor, - useSensors, - rectIntersection, - pointerWithin, - type PointerEvent as DndPointerEvent, -} from '@dnd-kit/core'; - -// Custom pointer sensor that ignores drag events from within dialogs -class DialogAwarePointerSensor extends PointerSensor { - static activators = [ - { - eventName: 'onPointerDown' as const, - handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => { - // Don't start drag if the event originated from inside a dialog - if ((event.target as Element)?.closest?.('[role="dialog"]')) { - return false; - } - return true; - }, - }, - ]; -} -import { useAppStore, Feature } from '@/store/app-store'; -import { getElectronAPI } from '@/lib/electron'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import type { AutoModeEvent } from '@/types/electron'; -import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types'; -import { pathsEqual } from '@/lib/utils'; -import { toast } from 'sonner'; -import { getBlockingDependencies } from '@automaker/dependency-resolver'; -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 { KanbanBoard } from './board-view/kanban-board'; -import { - AddFeatureDialog, - AgentOutputModal, - BacklogPlanDialog, - CompletedFeaturesModal, - ArchiveAllVerifiedDialog, - DeleteCompletedFeatureDialog, - EditFeatureDialog, - FollowUpDialog, - PlanApprovalDialog, -} from './board-view/dialogs'; -import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; -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 type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types'; -import { COLUMNS } from './board-view/constants'; -import { - useBoardFeatures, - useBoardDragDrop, - useBoardActions, - useBoardKeyboardShortcuts, - useBoardColumnFeatures, - useBoardEffects, - useBoardBackground, - useBoardPersistence, - useFollowUpState, - useSelectionMode, -} from './board-view/hooks'; -import { SelectionActionBar } from './board-view/components'; -import { MassEditDialog } from './board-view/dialogs'; -import { InitScriptIndicator } from './board-view/init-script-indicator'; -import { useInitScriptEvents } from '@/hooks/use-init-script-events'; - -// Stable empty array to avoid infinite loop in selector -const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; - -const logger = createLogger('Board'); - -export function BoardView() { - const { - currentProject, - maxConcurrency, - setMaxConcurrency, - defaultSkipTests, - specCreatingForProject, - setSpecCreatingForProject, - pendingPlanApproval, - setPendingPlanApproval, - updateFeature, - getCurrentWorktree, - setCurrentWorktree, - getWorktrees, - setWorktrees, - useWorktrees, - enableDependencyBlocking, - skipVerificationInAutoMode, - planUseSelectedWorktreeBranch, - addFeatureUseSelectedWorktreeBranch, - isPrimaryWorktreeBranch, - getPrimaryWorktreeBranch, - setPipelineConfig, - } = useAppStore(); - // Subscribe to pipelineConfigByProject to trigger re-renders when it changes - const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); - // Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes - const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); - // Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes - const showInitScriptIndicatorByProject = useAppStore( - (state) => state.showInitScriptIndicatorByProject - ); - const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator); - const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch); - 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 [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = 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); - - // State for spawn task mode - const [spawnParentFeature, setSpawnParentFeature] = 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); - - // Backlog plan dialog state - const [showPlanDialog, setShowPlanDialog] = useState(false); - const [pendingBacklogPlan, setPendingBacklogPlan] = useState(null); - const [isGeneratingPlan, setIsGeneratingPlan] = useState(false); - - // Pipeline settings dialog state - const [showPipelineSettings, setShowPipelineSettings] = useState(false); - - // Follow-up state hook - const { - showFollowUpDialog, - followUpFeature, - followUpPrompt, - followUpImagePaths, - followUpPreviewMap, - followUpPromptHistory, - setShowFollowUpDialog, - setFollowUpFeature, - setFollowUpPrompt, - setFollowUpImagePaths, - setFollowUpPreviewMap, - handleFollowUpDialogChange, - addToPromptHistory, - } = useFollowUpState(); - - // Selection mode hook for mass editing - const { - isSelectionMode, - selectedFeatureIds, - selectedCount, - toggleSelectionMode, - toggleFeatureSelection, - selectAll, - clearSelection, - exitSelectionMode, - } = useSelectionMode(); - const [showMassEditDialog, setShowMassEditDialog] = useState(false); - - // 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) { - logger.error('Error checking context:', error); - return false; - } - }, - [currentProject] - ); - - // Use board effects hook - useBoardEffects({ - currentProject, - specCreatingForProject, - setSpecCreatingForProject, - checkContextExists, - features: hookFeatures, - isLoading, - featuresWithContext, - setFeaturesWithContext, - }); - - // Load pipeline config when project changes - useEffect(() => { - if (!currentProject?.path) return; - - const loadPipelineConfig = async () => { - try { - const api = getHttpApiClient(); - const result = await api.pipeline.getConfig(currentProject.path); - if (result.success && result.config) { - setPipelineConfig(currentProject.path, result.config); - } - } catch (error) { - logger.error('Failed to load pipeline config:', error); - } - }; - - loadPipelineConfig(); - }, [currentProject?.path, setPipelineConfig]); - - // 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(); - - // Init script events hook - subscribe to worktree init script events - useInitScriptEvents(currentProject?.path ?? null); - - // Keyboard shortcuts hook will be initialized after actions hook - - // Prevent hydration issues - useEffect(() => { - setIsMounted(true); - }, []); - - const sensors = useSensors( - useSensor(DialogAwarePointerSensor, { - 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) { - logger.error('Error fetching branches:', error); - setBranchSuggestions([]); - } - }; - - fetchBranches(); - }, [currentProject, worktreeRefreshKey]); - - // Calculate unarchived card counts per branch - const branchCardCounts = useMemo(() => { - return hookFeatures.reduce( - (counts, feature) => { - if (feature.status !== 'completed') { - const branch = feature.branchName ?? 'main'; - counts[branch] = (counts[branch] || 0) + 1; - } - return counts; - }, - {} as Record - ); - }, [hookFeatures]); - - // 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, - }); - - // Memoize the removed worktrees handler to prevent infinite loops - const handleRemovedWorktrees = useCallback( - (removedWorktrees: Array<{ path: string; branch: string }>) => { - // Reset features that were assigned to the removed worktrees (by branch) - hookFeatures.forEach((feature) => { - const matchesRemovedWorktree = removedWorktrees.some((removed) => { - // Match by branch name since worktreePath is no longer stored - return feature.branchName === removed.branch; - }); - - if (matchesRemovedWorktree) { - // 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, updateFeature, persistFeatureUpdate] - ); - - // 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) 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 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 - // Find the worktree that matches the current selection, or use main worktree - const selectedWorktree = useMemo(() => { - if (currentWorktreePath === null) { - // Primary worktree selected - find the main worktree - return worktrees.find((w) => w.isMain); - } else { - // Specific worktree selected - find it by path - return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)); - } - }, [worktrees, currentWorktreePath]); - - // Get the current branch from the selected worktree (not from store which may be stale) - const currentWorktreeBranch = selectedWorktree?.branch ?? null; - - // Get the branch for the currently selected worktree (for defaulting new features) - // Use the branch from selectedWorktree, 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, - handleArchiveAllVerified, - } = 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), - onWorktreeAutoSelect: (newWorktree) => { - if (!currentProject) return; - // Check if worktree already exists in the store (by branch name) - const currentWorktrees = getWorktrees(currentProject.path); - const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch); - - // Only add if it doesn't already exist (to avoid duplicates) - if (!existingWorktree) { - const newWorktreeInfo = { - path: newWorktree.path, - branch: newWorktree.branch, - isMain: false, - isCurrent: false, - hasWorktree: true, - }; - setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]); - } - // Select the worktree (whether it existed or was just added) - setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch); - }, - currentWorktreeBranch, - }); - - // Handler for bulk updating multiple features - const handleBulkUpdate = useCallback( - async (updates: Partial) => { - if (!currentProject || selectedFeatureIds.size === 0) return; - - try { - const api = getHttpApiClient(); - const featureIds = Array.from(selectedFeatureIds); - const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates); - - if (result.success) { - // Update local state - featureIds.forEach((featureId) => { - updateFeature(featureId, updates); - }); - toast.success(`Updated ${result.updatedCount} features`); - exitSelectionMode(); - } else { - toast.error('Failed to update some features', { - description: `${result.failedCount} features failed to update`, - }); - } - } catch (error) { - logger.error('Bulk update failed:', error); - toast.error('Failed to update features'); - } - }, - [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode] - ); - - // Handler for bulk deleting multiple features - const handleBulkDelete = useCallback(async () => { - if (!currentProject || selectedFeatureIds.size === 0) return; - - try { - const api = getHttpApiClient(); - const featureIds = Array.from(selectedFeatureIds); - const result = await api.features.bulkDelete(currentProject.path, featureIds); - - const successfullyDeletedIds = - result.results?.filter((r) => r.success).map((r) => r.featureId) ?? []; - - if (successfullyDeletedIds.length > 0) { - // Delete from local state without calling the API again - successfullyDeletedIds.forEach((featureId) => { - useAppStore.getState().removeFeature(featureId); - }); - toast.success(`Deleted ${successfullyDeletedIds.length} features`); - } - - if (result.failedCount && result.failedCount > 0) { - toast.error('Failed to delete some features', { - description: `${result.failedCount} features failed to delete`, - }); - } - - // Exit selection mode and reload if the operation was at least partially processed. - if (result.results) { - exitSelectionMode(); - loadFeatures(); - } else if (!result.success) { - toast.error('Failed to delete features', { description: result.error }); - } - } catch (error) { - logger.error('Bulk delete failed:', error); - toast.error('Failed to delete features'); - } - }, [currentProject, selectedFeatureIds, exitSelectionMode, loadFeatures]); - - // Get selected features for mass edit dialog - const selectedFeatures = useMemo(() => { - return hookFeatures.filter((f) => selectedFeatureIds.has(f.id)); - }, [hookFeatures, selectedFeatureIds]); - - // Get backlog feature IDs in current branch for "Select All" - const allSelectableFeatureIds = useMemo(() => { - return hookFeatures - .filter((f) => { - // Only backlog features - if (f.status !== 'backlog') return false; - - // Filter by current worktree branch - const featureBranch = f.branchName; - if (!featureBranch) { - // No branch assigned - only selectable on primary worktree - return currentWorktreePath === null; - } - if (currentWorktreeBranch === null) { - // Viewing main but branch hasn't been initialized - return currentProject?.path - ? isPrimaryWorktreeBranch(currentProject.path, featureBranch) - : false; - } - // Match by branch name - return featureBranch === currentWorktreeBranch; - }) - .map((f) => f.id); - }, [ - hookFeatures, - currentWorktreePath, - currentWorktreeBranch, - currentProject?.path, - isPrimaryWorktreeBranch, - ]); - - // Handler for addressing PR comments - creates a feature and starts it automatically - const handleAddressPRComments = useCallback( - async (worktree: WorktreeInfo, prInfo: PRInfo) => { - // Use a simple prompt that instructs the agent to read and address PR feedback - // The agent will fetch the PR comments directly, which is more reliable and up-to-date - const prNumber = prInfo.number; - const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`; - - // Create the feature - const featureData = { - title: `Address PR #${prNumber} Review Comments`, - category: 'PR Review', - description, - images: [], - imagePaths: [], - skipTests: defaultSkipTests, - model: 'opus' as const, - thinkingLevel: 'none' as const, - branchName: worktree.branch, - priority: 1, // High priority for PR feedback - planningMode: 'skip' as const, - requirePlanApproval: false, - }; - - // Capture existing feature IDs before adding - const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); - await handleAddFeature(featureData); - - // Find the newly created feature by looking for an ID that wasn't in the original set - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - - if (newFeature) { - await handleStartImplementation(newFeature); - } else { - logger.error('Could not find newly created feature to start it automatically.'); - toast.error('Failed to auto-start feature', { - description: 'The feature was created but could not be started automatically.', - }); - } - }, - [handleAddFeature, handleStartImplementation, defaultSkipTests] - ); - - // Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts - const handleResolveConflicts = useCallback( - async (worktree: WorktreeInfo) => { - const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`; - - // Create the feature - const featureData = { - title: `Resolve Merge Conflicts`, - category: 'Maintenance', - description, - images: [], - imagePaths: [], - skipTests: defaultSkipTests, - model: 'opus' as const, - thinkingLevel: 'none' as const, - branchName: worktree.branch, - priority: 1, // High priority for conflict resolution - planningMode: 'skip' as const, - requirePlanApproval: false, - }; - - // Capture existing feature IDs before adding - const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); - await handleAddFeature(featureData); - - // Find the newly created feature by looking for an ID that wasn't in the original set - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - - if (newFeature) { - await handleStartImplementation(newFeature); - } else { - logger.error('Could not find newly created feature to start it automatically.'); - toast.error('Failed to auto-start feature', { - description: 'The feature was created but could not be started automatically.', - }); - } - }, - [handleAddFeature, handleStartImplementation, defaultSkipTests] - ); - - // Handler for "Make" button - creates a feature and immediately starts it - const handleAddAndStartFeature = useCallback( - async (featureData: Parameters[0]) => { - // Capture existing feature IDs before adding - const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); - await handleAddFeature(featureData); - - // Find the newly created feature by looking for an ID that wasn't in the original set - const latestFeatures = useAppStore.getState().features; - const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); - - if (newFeature) { - await handleStartImplementation(newFeature); - } else { - logger.error('Could not find newly created feature to start it automatically.'); - toast.error('Failed to auto-start feature', { - description: 'The feature was created but could not be started automatically.', - }); - } - }, - [handleAddFeature, handleStartImplementation] - ); - - // Client-side auto mode: periodically check for backlog items and move them to in-progress - // Use a ref to track the latest auto mode state so async operations always check the current value - const autoModeRunningRef = useRef(autoMode.isRunning); - useEffect(() => { - autoModeRunningRef.current = autoMode.isRunning; - }, [autoMode.isRunning]); - - // Use a ref to track the latest features to avoid effect re-runs when features change - const hookFeaturesRef = useRef(hookFeatures); - useEffect(() => { - 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()); - - // Listen to auto mode events to remove features from pending when they start running - useEffect(() => { - const api = getElectronAPI(); - if (!api?.autoMode) return; - - const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { - if (!currentProject) return; - - // Only process events for the current project - const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined; - if (eventProjectPath && eventProjectPath !== currentProject.path) { - return; - } - - switch (event.type) { - case 'auto_mode_feature_start': - // Feature is now confirmed running - remove from pending - if (event.featureId) { - pendingFeaturesRef.current.delete(event.featureId); - } - break; - - case 'auto_mode_feature_complete': - case 'auto_mode_error': - // Feature completed or errored - remove from pending if still there - if (event.featureId) { - pendingFeaturesRef.current.delete(event.featureId); - } - break; - } - }); - - return unsubscribe; - }, [currentProject]); - - // Listen for backlog plan events (for background generation) - useEffect(() => { - const api = getElectronAPI(); - if (!api?.backlogPlan) return; - - const unsubscribe = api.backlogPlan.onEvent( - (event: { type: string; result?: BacklogPlanResult; error?: string }) => { - if (event.type === 'backlog_plan_complete') { - setIsGeneratingPlan(false); - if (event.result && event.result.changes?.length > 0) { - setPendingBacklogPlan(event.result); - toast.success('Plan ready! Click to review.', { - duration: 10000, - action: { - label: 'Review', - onClick: () => setShowPlanDialog(true), - }, - }); - } else { - toast.info('No changes generated. Try again with a different prompt.'); - } - } else if (event.type === 'backlog_plan_error') { - setIsGeneratingPlan(false); - toast.error(`Plan generation failed: ${event.error}`); - } - } - ); - - return unsubscribe; - }, []); - - useEffect(() => { - logger.info( - '[AutoMode] Effect triggered - isRunning:', - autoMode.isRunning, - 'hasProject:', - !!currentProject - ); - if (!autoMode.isRunning || !currentProject) { - return; - } - - logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path); - let isChecking = false; - let isActive = true; // Track if this effect is still active - - const checkAndStartFeatures = async () => { - // Check if auto mode is still running and effect is still active - // Use ref to get the latest value, not the closure value - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Prevent concurrent executions - if (isChecking) { - return; - } - - isChecking = true; - try { - // Double-check auto mode is still running before proceeding - if (!isActive || !autoModeRunningRef.current || !currentProject) { - logger.debug( - '[AutoMode] Skipping check - isActive:', - isActive, - 'autoModeRunning:', - autoModeRunningRef.current, - 'hasProject:', - !!currentProject - ); - return; - } - - // Count currently running tasks + pending features - // Use ref to get the latest running tasks without causing effect re-runs - const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size; - const availableSlots = maxConcurrency - currentRunning; - logger.debug( - '[AutoMode] Checking features - running:', - currentRunning, - 'available slots:', - availableSlots - ); - - // No available slots, skip check - if (availableSlots <= 0) { - return; - } - - // Filter backlog features by the currently selected worktree branch - // This logic mirrors use-board-column-features.ts for consistency. - // HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree, - // so we fall back to "all backlog features" when none are visible in the current view. - // Use ref to get the latest features without causing effect re-runs - const currentFeatures = hookFeaturesRef.current; - const backlogFeaturesInView = currentFeatures.filter((f) => { - if (f.status !== 'backlog') return false; - - const featureBranch = f.branchName; - - // Features without branchName are considered unassigned (show only on primary worktree) - if (!featureBranch) { - // No branch assigned - show only when viewing primary worktree - const isViewingPrimary = currentWorktreePath === null; - return isViewingPrimary; - } - - if (currentWorktreeBranch === null) { - // We're viewing main but branch hasn't been initialized yet - // Show features assigned to primary worktree's branch - return currentProject.path - ? isPrimaryWorktreeBranch(currentProject.path, featureBranch) - : false; - } - - // Match by branch name - return featureBranch === currentWorktreeBranch; - }); - - const backlogFeatures = - backlogFeaturesInView.length > 0 - ? backlogFeaturesInView - : currentFeatures.filter((f) => f.status === 'backlog'); - - logger.debug( - '[AutoMode] Features - total:', - currentFeatures.length, - 'backlog in view:', - backlogFeaturesInView.length, - 'backlog total:', - backlogFeatures.length - ); - - if (backlogFeatures.length === 0) { - logger.debug( - '[AutoMode] No backlog features found, statuses:', - currentFeatures.map((f) => f.status).join(', ') - ); - return; - } - - // Sort by priority (lower number = higher priority, priority 1 is highest) - const sortedBacklog = [...backlogFeatures].sort( - (a, b) => (a.priority || 999) - (b.priority || 999) - ); - - // Filter out features with blocking dependencies if dependency blocking is enabled - // NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we - // should NOT exclude blocked features in that mode. - const eligibleFeatures = - enableDependencyBlocking && !skipVerificationInAutoMode - ? sortedBacklog.filter((f) => { - const blockingDeps = getBlockingDependencies(f, currentFeatures); - if (blockingDeps.length > 0) { - logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps); - } - return blockingDeps.length === 0; - }) - : sortedBacklog; - - logger.debug( - '[AutoMode] Eligible features after dep check:', - eligibleFeatures.length, - 'dependency blocking enabled:', - enableDependencyBlocking - ); - - // Start features up to available slots - const featuresToStart = eligibleFeatures.slice(0, availableSlots); - const startImplementation = handleStartImplementationRef.current; - if (!startImplementation) { - return; - } - - logger.info( - '[AutoMode] Starting', - featuresToStart.length, - 'features:', - featuresToStart.map((f) => f.id).join(', ') - ); - - for (const feature of featuresToStart) { - // Check again before starting each feature - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Simplified: No worktree creation on client - server derives workDir from feature.branchName - // If feature has no branchName, assign it to the primary branch so it can run consistently - // even when the user is viewing a non-primary worktree. - if (!feature.branchName) { - const primaryBranch = - (currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || - 'main'; - await persistFeatureUpdate(feature.id, { - branchName: primaryBranch, - }); - } - - // Final check before starting implementation - if (!isActive || !autoModeRunningRef.current || !currentProject) { - return; - } - - // Start the implementation - server will derive workDir from feature.branchName - const started = await startImplementation(feature); - - // If successfully started, track it as pending until we receive the start event - if (started) { - pendingFeaturesRef.current.add(feature.id); - } - } - } finally { - isChecking = false; - } - }; - - // Check immediately, then every 3 seconds - checkAndStartFeatures(); - const interval = setInterval(checkAndStartFeatures, 3000); - - return () => { - // Mark as inactive to prevent any pending async operations from continuing - isActive = false; - clearInterval(interval); - // Clear pending features when effect unmounts or dependencies change - pendingFeaturesRef.current.clear(); - }; - }, [ - autoMode.isRunning, - currentProject, - // 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, - currentWorktreePath, - getPrimaryWorktreeBranch, - isPrimaryWorktreeBranch, - enableDependencyBlocking, - skipVerificationInAutoMode, - persistFeatureUpdate, - ]); - - // 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, - }); - - // 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 { - logger.error('Failed to approve plan:', result.error); - } - } catch (error) { - logger.error('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 { - logger.error('Failed to reject plan:', result.error); - } - } catch (error) { - logger.error('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 */} - { - if (enabled) { - autoMode.start(); - } else { - autoMode.stop(); - } - }} - onOpenPlanDialog={() => setShowPlanDialog(true)} - isMounted={isMounted} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} - isCreatingSpec={isCreatingSpec} - creatingSpecProjectPath={creatingSpecProjectPath} - onShowBoardBackground={() => setShowBoardBackgroundModal(true)} - onShowCompletedModal={() => setShowCompletedModal(true)} - completedCount={completedFeatures.length} - /> - - {/* Worktree Panel - conditionally rendered based on visibility setting */} - {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( - 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); - }} - onAddressPRComments={handleAddressPRComments} - onResolveConflicts={handleResolveConflicts} - onRemovedWorktrees={handleRemovedWorktrees} - runningFeatureIds={runningAutoTasks} - branchCardCounts={branchCardCounts} - features={hookFeatures.map((f) => ({ - id: f.id, - branchName: f.branchName, - }))} - /> - )} - - {/* Main Content Area */} -
- {/* View Content - Kanban Board */} - setEditingFeature(feature)} onDelete={(featureId) => handleDeleteFeature(featureId)} - onViewOutput={handleViewOutput} - onVerify={handleVerifyFeature} - onResume={handleResumeFeature} - onForceStop={handleForceStopFeature} - onManualVerify={handleManualVerify} - onMoveBackToInProgress={handleMoveBackToInProgress} - onFollowUp={handleOpenFollowUp} - onComplete={handleCompleteFeature} - onImplement={handleStartImplementation} - onViewPlan={(feature) => setViewPlanFeature(feature)} - onApprovePlan={handleOpenApprovalDialog} - onSpawnTask={(feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); - }} - featuresWithContext={featuresWithContext} - runningAutoTasks={runningAutoTasks} - onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} - onAddFeature={() => setShowAddDialog(true)} - pipelineConfig={ - currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null - } - onOpenPipelineSettings={() => setShowPipelineSettings(true)} - isSelectionMode={isSelectionMode} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onToggleSelectionMode={toggleSelectionMode} - isDragging={activeFeature !== null} - onAiSuggest={() => setShowPlanDialog(true)} - /> -
- - {/* Selection Action Bar */} - {isSelectionMode && ( - setShowMassEditDialog(true)} - onDelete={handleBulkDelete} - onClear={clearSelection} - onSelectAll={() => selectAll(allSelectableFeatureIds)} - /> - )} - - {/* Mass Edit Dialog */} - setShowMassEditDialog(false)} - selectedFeatures={selectedFeatures} - onApply={handleBulkUpdate} - /> - - {/* 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 */} - { - setShowAddDialog(open); - if (!open) { - setSpawnParentFeature(null); - } - }} - onAdd={handleAddFeature} - onAddAndStart={handleAddAndStartFeature} - categorySuggestions={categorySuggestions} - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - defaultSkipTests={defaultSkipTests} - defaultBranch={selectedWorktreeBranch} - currentBranch={currentWorktreeBranch || undefined} - isMaximized={isMaximized} - parentFeature={spawnParentFeature} - allFeatures={hookFeatures} - // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode - selectedNonMainWorktreeBranch={ - addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null - ? currentWorktreeBranch || undefined - : undefined - } - // When the worktree setting is disabled, force 'current' branch mode - forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} - /> - - {/* Edit Feature Dialog */} - setEditingFeature(null)} - onUpdate={handleUpdateFeature} - categorySuggestions={categorySuggestions} - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentWorktreeBranch || undefined} - isMaximized={isMaximized} - allFeatures={hookFeatures} - /> - - {/* Agent Output Modal */} - setShowOutputModal(false)} - featureDescription={outputFeature?.description || ''} - featureId={outputFeature?.id || ''} - featureStatus={outputFeature?.status} - onNumberKeyPress={handleOutputModalNumberKeyPress} - /> - - {/* Archive All Verified Dialog */} - { - await handleArchiveAllVerified(); - setShowArchiveAllVerifiedDialog(false); - }} - /> - - {/* Pipeline Settings Dialog */} - setShowPipelineSettings(false)} - projectPath={currentProject.path} - pipelineConfig={pipelineConfigByProject[currentProject.path] || null} - onSave={async (config) => { - const api = getHttpApiClient(); - const result = await api.pipeline.saveConfig(currentProject.path, config); - if (!result.success) { - throw new Error(result.error || 'Failed to save pipeline config'); - } - setPipelineConfig(currentProject.path, config); - }} - /> - - {/* Follow-Up Prompt Dialog */} - - - {/* Backlog Plan Dialog */} - setShowPlanDialog(false)} - projectPath={currentProject.path} - onPlanApplied={loadFeatures} - pendingPlanResult={pendingBacklogPlan} - setPendingPlanResult={setPendingBacklogPlan} - isGeneratingPlan={isGeneratingPlan} - setIsGeneratingPlan={setIsGeneratingPlan} - currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined} - /> - - {/* 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} /> + ) : ( +
+ {/* Search Bar Row */} +
+ + + {/* Board Background & Detail Level Controls */} + setShowBoardBackgroundModal(true)} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + kanbanCardDetailLevel={kanbanCardDetailLevel} + onDetailLevelChange={setKanbanCardDetailLevel} + boardViewMode={boardViewMode} + onBoardViewModeChange={setBoardViewMode} + /> +
+ + {/* View Content - Kanban or Graph */} + {boardViewMode === 'kanban' ? ( + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasks} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + pipelineConfig={ + currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null + } + onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onToggleSelectionMode={toggleSelectionMode} + onManageBacklog={enterBacklogManager} + // restore main-branch functionality: + onAddFeature={() => setShowAddDialog(true)} + onAiSuggest={() => setShowPlanDialog(true)} + isDragging={activeFeature !== null} + /> + ) : ( + setEditingFeature(feature)} + onViewOutput={handleViewOutput} + onStartTask={handleStartImplementation} + onStopTask={handleForceStopFeature} + onResumeTask={handleResumeFeature} + onUpdateFeature={updateFeature} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} + onDeleteTask={(feature) => handleDeleteFeature(feature.id)} + /> + )} +
)} - - {/* 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 */} - f.branchName === selectedWorktreeForAction.branch).length - : 0 - } - defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} - 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 - update both local state and persist - const updates = { - branchName: null as unknown as string | undefined, - }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); - } - }); - - setWorktreeRefreshKey((k) => k + 1); - setSelectedWorktreeForAction(null); - }} - /> - - {/* Commit Worktree Dialog */} - { - setWorktreeRefreshKey((k) => k + 1); - setSelectedWorktreeForAction(null); - }} - /> - - {/* Create PR Dialog */} - { - // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL - if (prUrl && selectedWorktreeForAction?.branch) { - const branchName = selectedWorktreeForAction.branch; - const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName); - - // Update local state synchronously - featuresToUpdate.forEach((feature) => { - updateFeature(feature.id, { prUrl }); - }); - - // Persist changes asynchronously and in parallel - Promise.all( - featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl })) - ).catch((err) => logger.error('Error in handleMove:', err)); - } - setWorktreeRefreshKey((k) => k + 1); - setSelectedWorktreeForAction(null); - }} - /> - - {/* Create Branch Dialog */} - { - setWorktreeRefreshKey((k) => k + 1); - setSelectedWorktreeForAction(null); - }} - /> - - {/* Init Script Indicator - floating overlay for worktree init script status */} - {getShowInitScriptIndicator(currentProject.path) && ( - - )} -
- ); -} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/backlog-manager.tsx b/apps/ui/src/components/views/board-view/backlog-manager/backlog-manager.tsx new file mode 100644 index 000000000..5c342ec36 --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/backlog-manager.tsx @@ -0,0 +1,253 @@ +import { RefreshCw, ArrowLeft, AlertCircle, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { Feature } from '@/store/app-store'; +import { useBacklogManager, ImportError } from './hooks/use-backlog-manager'; +import { BacklogToolbar } from './backlog-toolbar'; +import { BacklogTable } from './backlog-table'; +import { BulkActionsBar } from './bulk-actions-bar'; + +/** + * Props for the BacklogManager component + */ +interface BacklogManagerProps { + /** Current project information */ + currentProject: { path: string; id: string; name?: string } | null; + /** Callback to exit backlog manager and return to Kanban view */ + onExitBacklogManager: () => void; + /** Callback when edit button is clicked on a feature */ + onEdit?: (feature: Feature) => void; + /** Callback when delete button is clicked on a feature */ + onDelete?: (featureId: string) => void; +} + +/** + * Inline error banner for displaying import failures + */ +function ImportErrorsBanner({ + errors, + onDismiss, +}: { + errors: ImportError[]; + onDismiss: () => void; +}) { + if (errors.length === 0) return null; + + return ( +
+ +
+

+ Failed to import {errors.length} file{errors.length !== 1 ? 's' : ''} +

+
    + {errors.map((err, index) => ( +
  • + {err.filename}: {err.error} +
  • + ))} +
+
+ +
+ ); +} + +/** + * Empty state component when there are no backlog items + */ +function EmptyBacklogState() { + return ( +
+
+ +
+

No backlog items

+

+ Your backlog is empty. Import files or add features from the Kanban board to get started. +

+
+ ); +} + +/** + * BacklogManager - A table-based view for managing backlog items + * + * Provides efficient bulk operations like: + * - Import TXT files + * - Bulk category changes + * - Bulk delete with concurrency-limited API calls + * - Search and filter by category + */ +export function BacklogManager({ + currentProject, + onExitBacklogManager, + onEdit, + onDelete, +}: BacklogManagerProps) { + const backlogManager = useBacklogManager({ currentProject }); + + const { + // Backlog features + filteredFeatures, + isLoading, + + // Selection + selectedIds, + selectedCount, + toggleSelection, + selectAll, + clearSelection, + isSelected, + + // Search + searchQuery, + setSearchQuery, + + // Category filter + selectedCategories, + setSelectedCategories, + availableCategories, + + // Import errors - the key feature of T004 + importErrors, + setImportErrors, + clearImportErrors, + + // Bulk operations - T008 feature + bulkDelete, + bulkUpdateCategory, + + // Feature operations + createFeature, + updateFeature, + + // Refresh + refetchFeatures, + } = backlogManager; + + // Handler for inline category editing in BacklogRow + const handleCategoryChange = async (featureId: string, category: string) => { + await updateFeature(featureId, { category }); + }; + + // Show loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Backlog Manager

+ + ({filteredFeatures.length} item{filteredFeatures.length !== 1 ? 's' : ''}) + +
+ + +
+ + {/* Main content area */} +
+ {/* Import errors banner */} + + + {/* Toolbar with search, filters, import */} + + + {/* Table or empty state */} + {filteredFeatures.length === 0 ? ( + + ) : ( + onDelete(feature.id) : undefined} + /> + )} + + {/* Bulk actions bar for selected items */} + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/backlog-row.tsx b/apps/ui/src/components/views/board-view/backlog-manager/backlog-row.tsx new file mode 100644 index 000000000..5ac4857ef --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/backlog-row.tsx @@ -0,0 +1,352 @@ +import { useCallback, useMemo, useState, useRef, useEffect } from 'react'; +import { Tag, X, Edit, Trash2 } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CategoryAutocomplete } from '@/components/ui/category-autocomplete'; +import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; +import { cn } from '@/lib/utils'; +import { Feature } from '@/store/app-store'; + +/** + * Props for the BacklogRow component + */ +export interface BacklogRowProps { + /** The feature to display */ + feature: Feature; + /** Whether this row is selected */ + isSelected: boolean; + /** Callback when selection checkbox is toggled */ + onSelect: () => void; + /** Callback when category is changed */ + onCategoryChange: (featureId: string, category: string) => Promise; + /** Available categories for autocomplete suggestions */ + availableCategories: string[]; + /** Optional callback when row is clicked (for future expansion) */ + onRowClick?: (feature: Feature) => void; + /** Callback when edit button is clicked */ + onEdit?: (feature: Feature) => void; + /** Callback when delete button is clicked */ + onDelete?: (feature: Feature) => void; +} + +/** + * BacklogRow - A table row component for displaying a single backlog item + * + * Features: + * - Selection checkbox + * - Title and description display (with truncation) + * - Inline category editing via click-to-edit pattern + * - Visual feedback for selected state + * - Keyboard navigation support (Escape to cancel edit) + * + * The inline category editing allows users to: + * 1. Click on the category badge/placeholder to enter edit mode + * 2. Select from existing categories or create new ones + * 3. Changes are saved immediately when selected + * 4. Press Escape or click Cancel to discard changes + */ +export function BacklogRow({ + feature, + isSelected, + onSelect, + onCategoryChange, + availableCategories, + onRowClick, + onEdit, + onDelete, +}: BacklogRowProps) { + // Edit mode state for inline category editing + const [isEditingCategory, setIsEditingCategory] = useState(false); + const [editingCategoryValue, setEditingCategoryValue] = useState(feature.category || ''); + const [isSaving, setIsSaving] = useState(false); + const categoryEditRef = useRef(null); + + // Delete confirmation dialog state + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // Handle row click (but not on interactive elements) + const handleRowClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + // Don't trigger row click if clicking on checkbox, buttons, or category editor + if ( + target.closest('button') || + target.closest('[role="checkbox"]') || + target.closest('input') || + target.closest('[data-category-editor]') + ) { + return; + } + onRowClick?.(feature); + }, + [feature, onRowClick] + ); + + // Handle checkbox click (prevent row click propagation) + const handleCheckboxClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + // Enter category edit mode + const handleStartCategoryEdit = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setEditingCategoryValue(feature.category || ''); + setIsEditingCategory(true); + }, + [feature.category] + ); + + // Save category change + const handleSaveCategory = useCallback(async () => { + if (isSaving) return; + + // Only save if value changed + if (editingCategoryValue !== (feature.category || '')) { + setIsSaving(true); + try { + await onCategoryChange(feature.id, editingCategoryValue); + } catch (error) { + // Reset to original value on error + setEditingCategoryValue(feature.category || ''); + } finally { + setIsSaving(false); + } + } + setIsEditingCategory(false); + }, [editingCategoryValue, feature.category, feature.id, onCategoryChange, isSaving]); + + // Cancel category edit + const handleCancelCategoryEdit = useCallback(() => { + setEditingCategoryValue(feature.category || ''); + setIsEditingCategory(false); + }, [feature.category]); + + // Auto-save when a category is selected (blur or selection) + const handleCategorySelect = useCallback( + async (value: string) => { + setEditingCategoryValue(value); + // Only save if value changed + if (value !== (feature.category || '')) { + setIsSaving(true); + try { + await onCategoryChange(feature.id, value); + } catch (error) { + // Reset to original value on error + setEditingCategoryValue(feature.category || ''); + } finally { + setIsSaving(false); + } + } + setIsEditingCategory(false); + }, + [feature.category, feature.id, onCategoryChange] + ); + + // Handle keyboard events for edit mode + useEffect(() => { + if (!isEditingCategory) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancelCategoryEdit(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isEditingCategory, handleCancelCategoryEdit]); + + // Click outside to save + useEffect(() => { + if (!isEditingCategory) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // Check if click is outside the category editor area + if (categoryEditRef.current && !categoryEditRef.current.contains(target)) { + // Check if click is inside a popover (autocomplete dropdown) + if (!target.closest('[data-radix-popper-content-wrapper]')) { + handleSaveCategory(); + } + } + }; + + // Delay adding the listener to avoid immediate trigger + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditingCategory, handleSaveCategory]); + + // Truncate description for display + const truncatedDescription = useMemo(() => { + const desc = feature.description || ''; + if (desc.length <= 150) return desc; + return desc.slice(0, 150).trim() + '...'; + }, [feature.description]); + + // Display title or placeholder + const displayTitle = feature.title?.trim() || '(No title)'; + const hasTitle = Boolean(feature.title?.trim()); + + return ( + + {/* Selection checkbox */} + + + + + {/* Title */} + + + {displayTitle} + + + + {/* Description */} + +

+ {truncatedDescription || '(No description)'} +

+ + + {/* Category - Inline editable */} + + {isEditingCategory ? ( +
e.stopPropagation()} + > + + +
+ ) : ( + + )} + + + {/* Actions column */} + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ + {/* Delete Confirmation Dialog */} + onDelete?.(feature)} + title="Delete Feature" + description="Are you sure you want to delete this feature? This action cannot be undone." + testId={`delete-dialog-${feature.id}`} + confirmTestId={`confirm-delete-${feature.id}`} + /> + + + ); +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/backlog-table.tsx b/apps/ui/src/components/views/board-view/backlog-manager/backlog-table.tsx new file mode 100644 index 000000000..8e215e952 --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/backlog-table.tsx @@ -0,0 +1,458 @@ +import { useCallback, useState, useMemo, useEffect } from 'react'; +import { + ChevronUp, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import { Feature } from '@/store/app-store'; +import { BacklogRow } from './backlog-row'; + +/** + * Sort configuration for columns + */ +export type SortField = 'title' | 'description' | 'category'; +export type SortDirection = 'asc' | 'desc'; + +export interface SortConfig { + field: SortField; + direction: SortDirection; +} + +/** + * Props for the BacklogTable component + */ +export interface BacklogTableProps { + /** Filtered features to display */ + features: Feature[]; + /** Set of selected feature IDs */ + selectedIds: Set; + /** Toggle selection for a feature */ + toggleSelection: (featureId: string) => void; + /** Select all visible features */ + selectAll: () => void; + /** Clear all selections */ + clearSelection: () => void; + /** Check if a feature is selected */ + isSelected: (featureId: string) => boolean; + /** Callback when category is changed for a feature */ + onCategoryChange: (featureId: string, category: string) => Promise; + /** Available categories for autocomplete suggestions */ + availableCategories: string[]; + /** Callback when a row is clicked (optional, for future expansion) */ + onRowClick?: (feature: Feature) => void; + /** Callback when edit button is clicked */ + onEdit?: (feature: Feature) => void; + /** Callback when delete button is clicked */ + onDelete?: (feature: Feature) => void; +} + +/** + * Page size options for pagination + */ +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; +type PageSize = (typeof PAGE_SIZE_OPTIONS)[number]; + +/** + * Pagination controls component + */ +function PaginationControls({ + currentPage, + totalPages, + pageSize, + totalItems, + onPageChange, + onPageSizeChange, +}: { + currentPage: number; + totalPages: number; + pageSize: PageSize; + totalItems: number; + onPageChange: (page: number) => void; + onPageSizeChange: (size: PageSize) => void; +}) { + const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalItems); + + return ( +
+ {/* Items per page selector */} +
+ Rows per page: + +
+ + {/* Page info and navigation */} +
+ + {startItem}-{endItem} of {totalItems} + + +
+ {/* First page */} + + + {/* Previous page */} + + + {/* Page indicator */} + + Page {currentPage} of {totalPages || 1} + + + {/* Next page */} + + + {/* Last page */} + +
+
+
+ ); +} + +/** + * Sortable column header component + */ +function SortableColumnHeader({ + label, + field, + currentSort, + onSort, + className, +}: { + label: string; + field: SortField; + currentSort: SortConfig | null; + onSort: (field: SortField) => void; + className?: string; +}) { + const isActive = currentSort?.field === field; + const direction = isActive ? currentSort.direction : null; + + return ( + onSort(field)} + data-testid={`sort-header-${field}`} + > +
+ {label} +
+ + +
+
+ + ); +} + +/** + * Table header with select all checkbox + */ +function BacklogTableHeader({ + allSelected, + someSelected, + onSelectAllChange, + featureCount, + sortConfig, + onSort, +}: { + allSelected: boolean; + someSelected: boolean; + onSelectAllChange: (checked: boolean) => void; + featureCount: number; + sortConfig: SortConfig | null; + onSort: (field: SortField) => void; +}) { + return ( + + + {/* Selection column */} + + + + {/* Title column - sortable */} + + {/* Description column - sortable */} + + {/* Category column - sortable */} + + {/* Actions column header */} + + {featureCount} item{featureCount !== 1 ? 's' : ''} + + + + ); +} + +/** + * BacklogTable - A dense table view for displaying and managing backlog items + * + * Features: + * - Select all / individual selection with checkboxes + * - Title, description (truncated), and category columns + * - Inline category editing via BacklogRow component + * - Sticky header for scrollable content + * - Visual feedback for selected rows + * - Sortable columns (click to sort by title, description, or category) + */ +export function BacklogTable({ + features, + selectedIds, + toggleSelection, + selectAll, + clearSelection, + isSelected, + onCategoryChange, + availableCategories, + onRowClick, + onEdit, + onDelete, +}: BacklogTableProps) { + // Sort state + const [sortConfig, setSortConfig] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + + // Reset to page 1 when features change (filters applied) or sort changes + useEffect(() => { + setCurrentPage(1); + }, [features.length, sortConfig]); + + // Calculate selection state for "select all" checkbox + const allSelected = features.length > 0 && selectedIds.size === features.length; + const someSelected = selectedIds.size > 0 && selectedIds.size < features.length; + + // Handle select all toggle + const handleSelectAllChange = useCallback( + (checked: boolean) => { + if (checked) { + selectAll(); + } else { + clearSelection(); + } + }, + [selectAll, clearSelection] + ); + + // Handle column sort toggle + const handleSort = useCallback((field: SortField) => { + setSortConfig((prev) => { + if (prev?.field === field) { + // Toggle direction or clear sort + if (prev.direction === 'asc') { + return { field, direction: 'desc' }; + } else { + return null; // Clear sort on third click + } + } + // New field, start with ascending + return { field, direction: 'asc' }; + }); + }, []); + + // Sort features based on current sort config + const sortedFeatures = useMemo(() => { + if (!sortConfig) return features; + + return [...features].sort((a, b) => { + let aValue: string; + let bValue: string; + + switch (sortConfig.field) { + case 'title': + aValue = a.title?.toLowerCase() || ''; + bValue = b.title?.toLowerCase() || ''; + break; + case 'description': + aValue = a.description?.toLowerCase() || ''; + bValue = b.description?.toLowerCase() || ''; + break; + case 'category': + aValue = a.category?.toLowerCase() || ''; + bValue = b.category?.toLowerCase() || ''; + break; + default: + return 0; + } + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [features, sortConfig]); + + // Pagination calculations + const totalPages = Math.ceil(sortedFeatures.length / pageSize); + + // Paginate sorted features + const paginatedFeatures = useMemo(() => { + const startIndex = (currentPage - 1) * pageSize; + return sortedFeatures.slice(startIndex, startIndex + pageSize); + }, [sortedFeatures, currentPage, pageSize]); + + // Handle page change + const handlePageChange = useCallback( + (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }, + [totalPages] + ); + + // Handle page size change + const handlePageSizeChange = useCallback((newSize: PageSize) => { + setPageSize(newSize); + setCurrentPage(1); // Reset to first page when changing page size + }, []); + + return ( +
+
+ + + + {paginatedFeatures.map((feature) => ( + toggleSelection(feature.id)} + onCategoryChange={onCategoryChange} + availableCategories={availableCategories} + onRowClick={onRowClick} + onEdit={onEdit} + onDelete={onDelete} + /> + ))} + +
+
+ + {/* Pagination controls */} + +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/backlog-toolbar.tsx b/apps/ui/src/components/views/board-view/backlog-manager/backlog-toolbar.tsx new file mode 100644 index 000000000..1386adb47 --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/backlog-toolbar.tsx @@ -0,0 +1,297 @@ +import { useRef, ChangeEvent, useCallback } from 'react'; +import { Search, X, Filter, Upload, ChevronDown } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { ImportError } from './hooks/use-backlog-manager'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('BacklogToolbar'); + +/** + * Props for the BacklogToolbar component + */ +interface BacklogToolbarProps { + /** Current search query */ + searchQuery: string; + /** Callback to update search query */ + onSearchChange: (query: string) => void; + /** Currently selected categories for filtering */ + selectedCategories: string[]; + /** Callback to update selected categories */ + onSelectedCategoriesChange: (categories: string[]) => void; + /** Available categories for filtering */ + availableCategories: string[]; + /** Callback to create a new feature (for import) */ + createFeature: (feature: Record) => Promise; + /** Callback to set import errors */ + setImportErrors: (errors: ImportError[]) => void; + /** Callback to refresh features list */ + refetchFeatures: () => Promise; + /** Whether import operations are disabled */ + disabled?: boolean; +} + +/** + * BacklogToolbar - Provides search, category filters, and import functionality + * + * Features: + * - Search input filtering by title and description + * - Multi-select category filter dropdown + * - Import button using native file picker (.txt and .md files) + */ +export function BacklogToolbar({ + searchQuery, + onSearchChange, + selectedCategories, + onSelectedCategoriesChange, + availableCategories, + createFeature, + setImportErrors, + refetchFeatures, + disabled = false, +}: BacklogToolbarProps) { + // Ref for hidden file input + const fileInputRef = useRef(null); + + // Toggle a category in the filter + const toggleCategory = useCallback( + (category: string) => { + if (selectedCategories.includes(category)) { + onSelectedCategoriesChange(selectedCategories.filter((c) => c !== category)); + } else { + onSelectedCategoriesChange([...selectedCategories, category]); + } + }, + [selectedCategories, onSelectedCategoriesChange] + ); + + // Clear all category filters + const clearCategoryFilters = useCallback(() => { + onSelectedCategoriesChange([]); + }, [onSelectedCategoriesChange]); + + // Handle file selection for import + const handleFileSelect = useCallback( + async (e: ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + logger.info(`Importing ${files.length} file(s)`); + const errors: ImportError[] = []; + + for (const file of files) { + try { + const description = await file.text(); // Verbatim contents + let created = false; + + // Attempt 1: empty string title + try { + const result = await createFeature({ + title: '', + description, + status: 'backlog', + }); + if (result) { + created = true; + logger.info(`Imported file "${file.name}" with empty title`); + } + } catch (error) { + logger.warn( + `Failed to create feature with empty title for "${file.name}", trying single space` + ); + + // Attempt 2: single space title + try { + const result = await createFeature({ + title: ' ', + description, + status: 'backlog', + }); + if (result) { + created = true; + logger.info(`Imported file "${file.name}" with single space title`); + } + } catch (retryError) { + // Both attempts failed - record error and skip file + const errorMessage = + retryError instanceof Error ? retryError.message : 'Unknown error'; + errors.push({ + filename: file.name, + error: errorMessage, + }); + logger.error(`Failed to import file "${file.name}": ${errorMessage}`); + } + } + + if (!created && errors.find((e) => e.filename === file.name) === undefined) { + // Feature creation returned null without throwing + errors.push({ + filename: file.name, + error: 'Failed to create feature', + }); + } + } catch (readError) { + // File read failed + const errorMessage = + readError instanceof Error ? readError.message : 'Failed to read file'; + errors.push({ + filename: file.name, + error: errorMessage, + }); + logger.error(`Failed to read file "${file.name}": ${errorMessage}`); + } + } + + // Update import errors state (displayed inline in BacklogManager) + if (errors.length > 0) { + setImportErrors(errors); + logger.warn(`Import completed with ${errors.length} error(s)`); + } else { + logger.info(`Successfully imported ${files.length} file(s)`); + } + + // Reset input to allow re-selecting same files + e.target.value = ''; + + // Refresh features list + await refetchFeatures(); + }, + [createFeature, setImportErrors, refetchFeatures] + ); + + const hasActiveFilters = selectedCategories.length > 0; + + return ( + +
+ {/* Search input */} +
+ + onSearchChange(e.target.value)} + className="pl-9 pr-8" + disabled={disabled} + data-testid="backlog-search-input" + /> + {searchQuery && ( + + )} +
+ + {/* Separator */} +
+ + {/* Category filter dropdown */} + + + + + + + + Filter by category + + + + Filter by Category + {hasActiveFilters && ( + + )} + + + {availableCategories.length === 0 ? ( +
+ No categories available +
+ ) : ( + availableCategories.map((category) => ( + toggleCategory(category)} + > + {category || '(Uncategorized)'} + + )) + )} +
+
+ + {/* Separator */} +
+ + {/* Import button */} + + + + + Import .txt or .md files + + + {/* Hidden file input for import */} + +
+ + ); +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/bulk-actions-bar.tsx b/apps/ui/src/components/views/board-view/backlog-manager/bulk-actions-bar.tsx new file mode 100644 index 000000000..ed0c4d475 --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/bulk-actions-bar.tsx @@ -0,0 +1,251 @@ +import { useState, useCallback } from 'react'; +import { Trash2, Tag, X, CheckSquare, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('BulkActionsBar'); + +/** + * Props for the BulkActionsBar component + */ +interface BulkActionsBarProps { + /** Number of selected items */ + selectedCount: number; + /** Total number of items in the current view */ + totalCount: number; + /** Array of selected feature IDs */ + selectedIds: string[]; + /** Available categories for bulk category change */ + availableCategories: string[]; + /** Callback to perform bulk delete (with concurrency cap of 5) */ + onBulkDelete: (featureIds: string[]) => Promise; + /** Callback to perform bulk category update */ + onBulkUpdateCategory: (featureIds: string[], category: string) => Promise; + /** Callback to select all items */ + onSelectAll: () => void; + /** Callback to clear selection */ + onClearSelection: () => void; +} + +/** + * BulkActionsBar - Floating action bar for bulk operations on selected items + * + * Features: + * - Shows count of selected items + * - Bulk delete with confirmation (uses parallel API calls with concurrency cap of 5) + * - Bulk category change via dropdown + * - Select all / clear selection controls + * - Loading states for async operations + */ +export function BulkActionsBar({ + selectedCount, + totalCount, + selectedIds, + availableCategories, + onBulkDelete, + onBulkUpdateCategory, + onSelectAll, + onClearSelection, +}: BulkActionsBarProps) { + // Loading states for async operations + const [isDeleting, setIsDeleting] = useState(false); + const [isUpdatingCategory, setIsUpdatingCategory] = useState(false); + + const allSelected = selectedCount === totalCount && totalCount > 0; + const isLoading = isDeleting || isUpdatingCategory; + + /** + * Handle bulk delete with confirmation + * Uses parallel API calls with concurrency cap of 5 (implemented in useBacklogManager hook) + */ + const handleBulkDelete = useCallback(async () => { + if (isLoading) return; + + // Confirm deletion + const confirmed = window.confirm( + `Are you sure you want to delete ${selectedCount} item${selectedCount !== 1 ? 's' : ''}? This action cannot be undone.` + ); + + if (!confirmed) return; + + setIsDeleting(true); + logger.info(`Starting bulk delete of ${selectedCount} items with concurrency cap of 5`); + + try { + await onBulkDelete(selectedIds); + logger.info(`Successfully deleted ${selectedCount} items`); + } catch (error) { + logger.error('Bulk delete failed:', error); + // Error handling is done in the hook, but we could show a toast here + } finally { + setIsDeleting(false); + } + }, [isLoading, selectedCount, selectedIds, onBulkDelete]); + + /** + * Handle bulk category change + */ + const handleCategoryChange = useCallback( + async (category: string) => { + if (isLoading || !category) return; + + setIsUpdatingCategory(true); + logger.info(`Starting bulk category update to "${category}" for ${selectedCount} items`); + + try { + await onBulkUpdateCategory(selectedIds, category); + logger.info(`Successfully updated category for ${selectedCount} items`); + } catch (error) { + logger.error('Bulk category update failed:', error); + } finally { + setIsUpdatingCategory(false); + } + }, + [isLoading, selectedCount, selectedIds, onBulkUpdateCategory] + ); + + // Don't render if nothing is selected - MUST be after all hooks are called + if (selectedCount === 0) return null; + + return ( + +
+ {/* Selection count */} + + {selectedCount} item{selectedCount !== 1 ? 's' : ''} selected + + +
+ + {/* Bulk actions */} +
+ {/* Bulk category change */} + + +
+ +
+
+ Change category for selected items +
+ + {/* Bulk delete */} + + + + + Delete selected items + +
+ +
+ + {/* Selection controls */} +
+ {/* Select all button (only show if not all selected) */} + {!allSelected && ( + + + + + Select all items + + )} + + {/* Clear selection */} + + + + + Clear selection (Esc) + +
+
+ + ); +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/hooks/use-backlog-manager.ts b/apps/ui/src/components/views/board-view/backlog-manager/hooks/use-backlog-manager.ts new file mode 100644 index 000000000..ccbe220fd --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/hooks/use-backlog-manager.ts @@ -0,0 +1,426 @@ +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useAppStore, Feature } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { mapWithConcurrency } from '@/lib/concurrent-utils'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('BacklogManager'); + +/** + * Error information for failed imports + */ +export interface ImportError { + filename: string; + error: string; +} + +/** + * Props for the useBacklogManager hook + */ +interface UseBacklogManagerProps { + currentProject: { path: string; id: string } | null; +} + +/** + * Return type for the useBacklogManager hook + */ +interface UseBacklogManagerReturn { + // Backlog features (filtered to status === 'backlog') + backlogFeatures: Feature[]; + filteredFeatures: Feature[]; + isLoading: boolean; + + // Selection state + selectedIds: Set; + selectedCount: number; + toggleSelection: (featureId: string) => void; + selectAll: () => void; + clearSelection: () => void; + isSelected: (featureId: string) => boolean; + + // Search state + searchQuery: string; + setSearchQuery: (query: string) => void; + + // Category filter state + selectedCategories: string[]; + setSelectedCategories: (categories: string[]) => void; + availableCategories: string[]; + + // Import errors state + importErrors: ImportError[]; + setImportErrors: (errors: ImportError[]) => void; + clearImportErrors: () => void; + + // Bulk operations + bulkDelete: (featureIds: string[]) => Promise; + bulkUpdateCategory: (featureIds: string[], category: string) => Promise; + + // Single feature operations + createFeature: (feature: Partial) => Promise; + updateFeature: (featureId: string, updates: Partial) => Promise; + deleteFeature: (featureId: string) => Promise; + + // Refresh + refetchFeatures: () => Promise; +} + +/** + * Hook for managing backlog items with selection, search, filtering, + * import error tracking, and bulk operations. + */ +export function useBacklogManager({ + currentProject, +}: UseBacklogManagerProps): UseBacklogManagerReturn { + const { features, setFeatures, updateFeature: storeUpdateFeature, removeFeature } = useAppStore(); + + // Loading state + const [isLoading, setIsLoading] = useState(false); + + // Selection state + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Search state + const [searchQuery, setSearchQuery] = useState(''); + + // Category filter state + const [selectedCategories, setSelectedCategories] = useState([]); + + // Import errors state + const [importErrors, setImportErrors] = useState([]); + + // Filter features to only backlog items + const backlogFeatures = useMemo(() => features.filter((f) => f.status === 'backlog'), [features]); + + // Get available categories from backlog features + const availableCategories = useMemo( + () => + Array.from( + new Set(backlogFeatures.map((f) => f.category).filter((c): c is string => Boolean(c))) + ).sort(), + [backlogFeatures] + ); + + // Apply search and category filters + const filteredFeatures = useMemo(() => { + let result = backlogFeatures; + + // Apply search filter (searches title and description) + if (searchQuery.trim()) { + const normalizedQuery = searchQuery.toLowerCase().trim(); + result = result.filter((f) => { + const titleMatch = f.title?.toLowerCase().includes(normalizedQuery); + const descMatch = f.description?.toLowerCase().includes(normalizedQuery); + return titleMatch || descMatch; + }); + } + + // Apply category filter + if (selectedCategories.length > 0) { + result = result.filter((f) => selectedCategories.includes(f.category)); + } + + return result; + }, [backlogFeatures, searchQuery, selectedCategories]); + + // Selection handlers + const toggleSelection = useCallback((featureId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(featureId)) { + next.delete(featureId); + } else { + next.add(featureId); + } + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setSelectedIds(new Set(filteredFeatures.map((f) => f.id))); + }, [filteredFeatures]); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback((featureId: string) => selectedIds.has(featureId), [selectedIds]); + + // Clear import errors + const clearImportErrors = useCallback(() => { + setImportErrors([]); + }, []); + + // Refetch features from API + const refetchFeatures = useCallback(async () => { + if (!currentProject) return; + + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return; + } + + const result = await api.features.getAll(currentProject.path); + + if (result.success && result.features) { + const featuresWithIds = result.features.map((f: any, index: number) => ({ + ...f, + id: f.id || `feature-${index}-${Date.now()}`, + status: f.status || 'backlog', + model: f.model || 'opus', + thinkingLevel: f.thinkingLevel || 'none', + })); + setFeatures(featuresWithIds); + } + } catch (error) { + logger.error('Failed to refetch features:', error); + } finally { + setIsLoading(false); + } + }, [currentProject, setFeatures]); + + // Create a new feature + const createFeature = useCallback( + async (feature: Partial): Promise => { + if (!currentProject) return null; + + try { + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return null; + } + + const newFeature: Feature = { + id: `feature-${Date.now()}`, + title: feature.title ?? '', + description: feature.description ?? '', + category: feature.category ?? '', + status: 'backlog', + steps: feature.steps ?? [], + ...feature, + } as Feature; + + const result = await api.features.create(currentProject.path, newFeature); + if (result.success && result.feature) { + storeUpdateFeature(result.feature.id, result.feature); + return result.feature; + } + return null; + } catch (error) { + logger.error('Failed to create feature:', error); + throw error; + } + }, + [currentProject, storeUpdateFeature] + ); + + // Update a feature + const updateFeature = useCallback( + async (featureId: string, updates: Partial) => { + if (!currentProject) return; + + try { + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return; + } + + const result = await api.features.update(currentProject.path, featureId, updates); + if (result.success && result.feature) { + storeUpdateFeature(result.feature.id, result.feature); + } + } catch (error) { + logger.error('Failed to update feature:', error); + throw error; + } + }, + [currentProject, storeUpdateFeature] + ); + + // Delete a single feature + const deleteFeature = useCallback( + async (featureId: string) => { + if (!currentProject) return; + + try { + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return; + } + + await api.features.delete(currentProject.path, featureId); + removeFeature(featureId); + } catch (error) { + logger.error('Failed to delete feature:', error); + throw error; + } + }, + [currentProject, removeFeature] + ); + + // Bulk delete features with concurrency cap of 5 + const bulkDelete = useCallback( + async (featureIds: string[]) => { + if (!currentProject || featureIds.length === 0) return; + + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return; + } + + logger.info(`Bulk deleting ${featureIds.length} features with concurrency cap of 5`); + + const results = await mapWithConcurrency( + featureIds, + async (id) => { + await api.features!.delete(currentProject.path, id); + return id; + }, + 5 // Concurrency limit + ); + + // Remove successfully deleted features from store + const deletedIds: string[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + deletedIds.push(featureIds[index]); + } else { + logger.error(`Failed to delete feature ${featureIds[index]}:`, result.reason); + } + }); + + // Update store by removing deleted features + deletedIds.forEach((id) => removeFeature(id)); + + // Clear selection for deleted items + setSelectedIds((prev) => { + const next = new Set(prev); + deletedIds.forEach((id) => next.delete(id)); + return next; + }); + + logger.info(`Successfully deleted ${deletedIds.length}/${featureIds.length} features`); + }, + [currentProject, removeFeature] + ); + + // Bulk update category with concurrency cap of 5 + const bulkUpdateCategory = useCallback( + async (featureIds: string[], category: string) => { + if (!currentProject || featureIds.length === 0) return; + + const api = getElectronAPI(); + if (!api.features) { + logger.error('Features API not available'); + return; + } + + logger.info(`Bulk updating category to "${category}" for ${featureIds.length} features`); + + const results = await mapWithConcurrency( + featureIds, + async (id) => { + const result = await api.features!.update(currentProject.path, id, { category }); + return { id, feature: result.feature }; + }, + 5 // Concurrency limit + ); + + // Update store for successful updates + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value.feature) { + storeUpdateFeature(result.value.id, result.value.feature); + } else if (result.status === 'rejected') { + logger.error( + `Failed to update category for feature ${featureIds[index]}:`, + result.reason + ); + } + }); + + const successCount = results.filter((r) => r.status === 'fulfilled').length; + logger.info( + `Successfully updated category for ${successCount}/${featureIds.length} features` + ); + }, + [currentProject, storeUpdateFeature] + ); + + // Clean up selection when features change (remove selected IDs that no longer exist) + useEffect(() => { + const backlogIds = new Set(backlogFeatures.map((f) => f.id)); + setSelectedIds((prev) => { + const next = new Set(); + prev.forEach((id) => { + if (backlogIds.has(id)) { + next.add(id); + } + }); + // Only update if there's a change + if (next.size !== prev.size) { + return next; + } + return prev; + }); + }, [backlogFeatures]); + + // Handle Escape key to clear selection + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && selectedIds.size > 0) { + clearSelection(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedIds.size, clearSelection]); + + return { + // Backlog features + backlogFeatures, + filteredFeatures, + isLoading, + + // Selection + selectedIds, + selectedCount: selectedIds.size, + toggleSelection, + selectAll, + clearSelection, + isSelected, + + // Search + searchQuery, + setSearchQuery, + + // Category filter + selectedCategories, + setSelectedCategories, + availableCategories, + + // Import errors + importErrors, + setImportErrors, + clearImportErrors, + + // Bulk operations + bulkDelete, + bulkUpdateCategory, + + // Single feature operations + createFeature, + updateFeature, + deleteFeature, + + // Refresh + refetchFeatures, + }; +} diff --git a/apps/ui/src/components/views/board-view/backlog-manager/index.ts b/apps/ui/src/components/views/board-view/backlog-manager/index.ts new file mode 100644 index 000000000..e59abd07f --- /dev/null +++ b/apps/ui/src/components/views/board-view/backlog-manager/index.ts @@ -0,0 +1,14 @@ +// Main component +export { BacklogManager } from './backlog-manager'; + +// Sub-components +export { BacklogToolbar } from './backlog-toolbar'; +export { BacklogTable } from './backlog-table'; +export type { BacklogTableProps } from './backlog-table'; +export { BacklogRow } from './backlog-row'; +export type { BacklogRowProps } from './backlog-row'; +export { BulkActionsBar } from './bulk-actions-bar'; + +// Hooks +export { useBacklogManager } from './hooks/use-backlog-manager'; +export type { ImportError } from './hooks/use-backlog-manager'; diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index e6b229ee7..14a86bd6a 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -2,9 +2,11 @@ import { useMemo } from 'react'; import { DndContext, DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button } from '@/components/ui/button'; + import { KanbanColumn, KanbanCard, EmptyStateCard } from './components'; import { Feature, useAppStore, formatShortcut } from '@/store/app-store'; -import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react'; +import { Archive, Settings2, CheckSquare, GripVertical, LayoutList, Plus } from 'lucide-react'; + import { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; import { getColumnsWithPipeline, type ColumnId } from './constants'; import type { PipelineConfig } from '@automaker/types'; @@ -46,12 +48,17 @@ interface KanbanBoardProps { onAddFeature: () => void; pipelineConfig: PipelineConfig | null; onOpenPipelineSettings?: () => void; + // Selection mode props isSelectionMode?: boolean; selectedFeatureIds?: Set; onToggleFeatureSelection?: (featureId: string) => void; onToggleSelectionMode?: () => void; - // Empty state action props + + // Backlog manager navigation (yours) + onManageBacklog?: () => void; + + // Empty state action props (main) onAiSuggest?: () => void; /** Whether currently dragging (hides empty states during drag) */ isDragging?: boolean; @@ -92,19 +99,16 @@ export function KanbanBoard({ selectedFeatureIds = new Set(), onToggleFeatureSelection, onToggleSelectionMode, + onManageBacklog, onAiSuggest, isDragging = false, isReadOnly = false, }: KanbanBoardProps) { - // Generate columns including pipeline steps const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); - // Get the keyboard shortcut for adding features const { keyboardShortcuts } = useAppStore(); const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; - // Use responsive column widths based on window size - // containerStyle handles centering and ensures columns fit without horizontal scroll in Electron const { columnWidth, containerStyle } = useResponsiveKanban(columns.length); return ( @@ -118,6 +122,7 @@ export function KanbanBoard({
{columns.map((column) => { const columnFeatures = getColumnFeatures(column.id as ColumnId); + return ( ) : column.id === 'backlog' ? (
+ {/* Your Manage Backlog button */} + + + {/* Main Add Feature button */} + + {/* Selection toggle */}