import { useMemo } from 'react'; import { 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 { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; import { getColumnsWithPipeline, type ColumnId } from './constants'; import type { PipelineConfig } from '@automaker/types'; import { cn } from '@/lib/utils'; interface KanbanBoardProps { activeFeature: Feature | null; getColumnFeatures: (columnId: ColumnId) => Feature[]; backgroundImageStyle: React.CSSProperties; backgroundSettings: { columnOpacity: number; columnBorderEnabled: boolean; hideScrollbar: boolean; cardOpacity: number; cardGlassmorphism: boolean; cardBorderEnabled: boolean; cardBorderOpacity: number; }; onEdit: (feature: Feature) => void; onDelete: (featureId: string) => void; onViewOutput: (feature: Feature) => void; onVerify: (feature: Feature) => void; onResume: (feature: Feature) => void; onForceStop: (feature: Feature) => void; onManualVerify: (feature: Feature) => void; onMoveBackToInProgress: (feature: Feature) => void; onFollowUp: (feature: Feature) => void; onComplete: (feature: Feature) => void; onImplement: (feature: Feature) => void; onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; onAddFeature: () => void; onShowCompletedModal: () => void; completedCount: number; pipelineConfig: PipelineConfig | null; onOpenPipelineSettings?: () => void; // Selection mode props isSelectionMode?: boolean; selectionTarget?: 'backlog' | 'waiting_approval' | null; selectedFeatureIds?: Set; onToggleFeatureSelection?: (featureId: string) => void; onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void; // Empty state action props onAiSuggest?: () => void; /** Whether currently dragging (hides empty states during drag) */ isDragging?: boolean; /** Whether the board is in read-only mode */ isReadOnly?: boolean; /** Additional className for custom styling (e.g., transition classes) */ className?: string; } const KANBAN_VIRTUALIZATION_THRESHOLD = 40; const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220; const KANBAN_CARD_GAP_PX = 10; const KANBAN_OVERSCAN_COUNT = 6; const VIRTUALIZATION_MEASURE_EPSILON_PX = 1; const REDUCED_CARD_OPACITY_PERCENT = 85; type VirtualListItem = { id: string }; interface VirtualListState { contentRef: RefObject; onScroll: (event: UIEvent) => void; itemIds: string[]; visibleItems: Item[]; totalHeight: number; offsetTop: number; startIndex: number; shouldVirtualize: boolean; registerItem: (id: string) => (node: HTMLDivElement | null) => void; } interface VirtualizedListProps { items: Item[]; isDragging: boolean; estimatedItemHeight: number; itemGap: number; overscan: number; virtualizationThreshold: number; children: (state: VirtualListState) => ReactNode; } function findIndexForOffset(itemEnds: number[], offset: number): number { let low = 0; let high = itemEnds.length - 1; let result = itemEnds.length; while (low <= high) { const mid = Math.floor((low + high) / 2); if (itemEnds[mid] >= offset) { result = mid; high = mid - 1; } else { low = mid + 1; } } return Math.min(result, itemEnds.length - 1); } // Virtualize long columns while keeping full DOM during drag interactions. function VirtualizedList({ items, isDragging, estimatedItemHeight, itemGap, overscan, virtualizationThreshold, children, }: VirtualizedListProps) { const contentRef = useRef(null); const measurementsRef = useRef>(new Map()); const scrollRafRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const [measureVersion, setMeasureVersion] = useState(0); const itemIds = useMemo(() => items.map((item) => item.id), [items]); const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold; const itemSizes = useMemo(() => { return items.map((item) => { const measured = measurementsRef.current.get(item.id); const resolvedHeight = measured ?? estimatedItemHeight; return resolvedHeight + itemGap; }); }, [items, estimatedItemHeight, itemGap, measureVersion]); const itemStarts = useMemo(() => { let offset = 0; return itemSizes.map((size) => { const start = offset; offset += size; return start; }); }, [itemSizes]); const itemEnds = useMemo(() => { return itemStarts.map((start, index) => start + itemSizes[index]); }, [itemStarts, itemSizes]); const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0; const { startIndex, endIndex, offsetTop } = useMemo(() => { if (!shouldVirtualize || items.length === 0) { return { startIndex: 0, endIndex: items.length, offsetTop: 0 }; } const firstVisible = findIndexForOffset(itemEnds, scrollTop); const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight); const overscannedStart = Math.max(0, firstVisible - overscan); const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1); return { startIndex: overscannedStart, endIndex: overscannedEnd, offsetTop: itemStarts[overscannedStart] ?? 0, }; }, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]); const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items; const onScroll = useCallback((event: UIEvent) => { const target = event.currentTarget; if (scrollRafRef.current !== null) { cancelAnimationFrame(scrollRafRef.current); } scrollRafRef.current = requestAnimationFrame(() => { setScrollTop(target.scrollTop); scrollRafRef.current = null; }); }, []); const registerItem = useCallback( (id: string) => (node: HTMLDivElement | null) => { if (!node || !shouldVirtualize) return; const measuredHeight = node.getBoundingClientRect().height; const previousHeight = measurementsRef.current.get(id); if ( previousHeight === undefined || Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX ) { measurementsRef.current.set(id, measuredHeight); setMeasureVersion((value) => value + 1); } }, [shouldVirtualize] ); useEffect(() => { const container = contentRef.current; if (!container || typeof window === 'undefined') return; const updateHeight = () => { setViewportHeight(container.clientHeight); }; updateHeight(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', updateHeight); return () => window.removeEventListener('resize', updateHeight); } const observer = new ResizeObserver(() => updateHeight()); observer.observe(container); return () => observer.disconnect(); }, []); useEffect(() => { if (!shouldVirtualize) return; const currentIds = new Set(items.map((item) => item.id)); for (const id of measurementsRef.current.keys()) { if (!currentIds.has(id)) { measurementsRef.current.delete(id); } } }, [items, shouldVirtualize]); useEffect(() => { return () => { if (scrollRafRef.current !== null) { cancelAnimationFrame(scrollRafRef.current); } }; }, []); return ( <> {children({ contentRef, onScroll, itemIds, visibleItems, totalHeight, offsetTop, startIndex, shouldVirtualize, registerItem, })} ); } export function KanbanBoard({ activeFeature, getColumnFeatures, backgroundImageStyle, backgroundSettings, onEdit, onDelete, onViewOutput, onVerify, onResume, onForceStop, onManualVerify, onMoveBackToInProgress, onFollowUp, onComplete, onImplement, onViewPlan, onApprovePlan, onSpawnTask, featuresWithContext, runningAutoTasks, onArchiveAllVerified, onAddFeature, onShowCompletedModal, completedCount, pipelineConfig, onOpenPipelineSettings, isSelectionMode = false, selectionTarget = null, selectedFeatureIds = new Set(), onToggleFeatureSelection, onToggleSelectionMode, onAiSuggest, isDragging = false, isReadOnly = false, className, }: KanbanBoardProps) { // Generate columns including pipeline steps const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); // Get the keyboard shortcut for adding features const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); 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 (
{columns.map((column) => { const columnFeatures = getColumnFeatures(column.id as ColumnId); return ( {({ contentRef, onScroll, itemIds, visibleItems, totalHeight, offsetTop, startIndex, shouldVirtualize, registerItem, }) => ( {columnFeatures.length > 0 && ( )}
) : column.id === 'backlog' ? (
) : column.id === 'waiting_approval' ? ( ) : column.id === 'in_progress' ? ( ) : column.isPipelineStep ? ( ) : undefined } footerAction={ column.id === 'backlog' ? ( ) : undefined } > {(() => { const reduceEffects = shouldVirtualize; const effectiveCardOpacity = reduceEffects ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) : backgroundSettings.cardOpacity; const effectiveGlassmorphism = backgroundSettings.cardGlassmorphism && !reduceEffects; return ( {/* Empty state card when column has no features */} {columnFeatures.length === 0 && !isDragging && ( )} {shouldVirtualize ? (
{visibleItems.map((feature, index) => { const absoluteIndex = startIndex + index; let shortcutKey: string | undefined; if (column.id === 'in_progress' && absoluteIndex < 10) { shortcutKey = absoluteIndex === 9 ? '0' : String(absoluteIndex + 1); } return (
onEdit(feature)} onDelete={() => onDelete(feature.id)} onViewOutput={() => onViewOutput(feature)} onVerify={() => onVerify(feature)} onResume={() => onResume(feature)} onForceStop={() => onForceStop(feature)} onManualVerify={() => onManualVerify(feature)} onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} onFollowUp={() => onFollowUp(feature)} onComplete={() => onComplete(feature)} onImplement={() => onImplement(feature)} onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} opacity={effectiveCardOpacity} glassmorphism={effectiveGlassmorphism} cardBorderEnabled={backgroundSettings.cardBorderEnabled} cardBorderOpacity={backgroundSettings.cardBorderOpacity} reduceEffects={reduceEffects} isSelectionMode={isSelectionMode} selectionTarget={selectionTarget} isSelected={selectedFeatureIds.has(feature.id)} onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} />
); })}
) : ( columnFeatures.map((feature, index) => { let shortcutKey: string | undefined; if (column.id === 'in_progress' && index < 10) { shortcutKey = index === 9 ? '0' : String(index + 1); } return ( onEdit(feature)} onDelete={() => onDelete(feature.id)} onViewOutput={() => onViewOutput(feature)} onVerify={() => onVerify(feature)} onResume={() => onResume(feature)} onForceStop={() => onForceStop(feature)} onManualVerify={() => onManualVerify(feature)} onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} onFollowUp={() => onFollowUp(feature)} onComplete={() => onComplete(feature)} onImplement={() => onImplement(feature)} onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} opacity={effectiveCardOpacity} glassmorphism={effectiveGlassmorphism} cardBorderEnabled={backgroundSettings.cardBorderEnabled} cardBorderOpacity={backgroundSettings.cardBorderOpacity} reduceEffects={reduceEffects} isSelectionMode={isSelectionMode} selectionTarget={selectionTarget} isSelected={selectedFeatureIds.has(feature.id)} onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} /> ); }) )}
); })()} )} ); })}
{activeFeature && (
{}} onDelete={() => {}} onViewOutput={() => {}} onVerify={() => {}} onResume={() => {}} onForceStop={() => {}} onManualVerify={() => {}} onMoveBackToInProgress={() => {}} onFollowUp={() => {}} onImplement={() => {}} onComplete={() => {}} onViewPlan={() => {}} onApprovePlan={() => {}} onSpawnTask={() => {}} hasContext={featuresWithContext.has(activeFeature.id)} isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)} opacity={backgroundSettings.cardOpacity} glassmorphism={backgroundSettings.cardGlassmorphism} cardBorderEnabled={backgroundSettings.cardBorderEnabled} cardBorderOpacity={backgroundSettings.cardBorderOpacity} />
)}
); }