perf(ui): smooth large lists and graphs

This commit is contained in:
DhanushSantosh
2026-01-19 19:38:56 +05:30
parent f987fc1f10
commit 9bb52f1ded
30 changed files with 1116 additions and 312 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -56,7 +56,7 @@ interface AgentInfoPanelProps {
isCurrentAutoTask?: boolean;
}
export function AgentInfoPanel({
export const AgentInfoPanel = memo(function AgentInfoPanel({
feature,
projectPath,
contextContent,
@@ -405,4 +405,4 @@ export function AgentInfoPanel({
onOpenChange={setIsSummaryDialogOpen}
/>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
@@ -32,7 +33,7 @@ interface CardActionsProps {
onApprovePlan?: () => void;
}
export function CardActions({
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
hasContext,
@@ -344,4 +345,4 @@ export function CardActions({
)}
</div>
);
}
});

View File

@@ -1,10 +1,11 @@
// @ts-nocheck
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -18,7 +19,7 @@ interface CardBadgesProps {
* CardBadges - Shows error badges below the card header
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
*/
export function CardBadges({ feature }: CardBadgesProps) {
export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) {
if (!feature.error) {
return null;
}
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipProvider>
</div>
);
}
});
interface PriorityBadgesProps {
feature: Feature;
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore(
useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking,
features: state.features,
}))
);
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
</div>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
useWorktrees: boolean;
}
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
export const CardContentSections = memo(function CardContentSections({
feature,
useWorktrees,
}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
})()}
</>
);
}
});

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { useState } from 'react';
import { memo, useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -37,7 +37,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
}
export function CardHeaderSection({
export const CardHeaderSection = memo(function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
@@ -378,4 +378,4 @@ export function CardHeaderSection({
/>
</CardHeader>
);
}
});

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Feature, useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
@@ -61,6 +62,7 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
reduceEffects?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
reduceEffects = false,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
const { useWorktrees, currentProject } = useAppStore();
const { useWorktrees, currentProject } = useAppStore(
useShallow((state) => ({
useWorktrees: state.useWorktrees,
currentProject: state.currentProject,
}))
);
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'kanban-card-content h-full relative',
reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
isInteractive &&
!reduceEffects &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
interface KanbanColumnProps {
id: string;
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
contentRef?: Ref<HTMLDivElement>;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
contentClassName?: string;
contentStyle?: CSSProperties;
disableItemSpacing?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
showBorder = true,
hideScrollbar = false,
width,
contentRef,
onScroll,
contentClassName,
contentStyle,
disableItemSpacing = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */}
<div
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
'relative z-10 flex-1 overflow-y-auto p-2',
!disableItemSpacing && 'space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
footerAction && 'pb-14',
contentClassName
)}
ref={contentRef}
onScroll={onScroll}
style={contentStyle}
>
{children}
</div>

View File

@@ -1,7 +1,11 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
import {
createFeatureMap,
getBlockingDependenciesFromMap,
resolveDependencies,
} from '@automaker/dependency-resolver';
type ColumnId = Feature['status'];
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
const isRunning = runningAutoTasks.includes(f.id);
const isRunning = runningTaskIds.has(f.id);
// Check if feature matches the current worktree by branchName
// Features without branchName are considered unassigned (show only on primary worktree)
@@ -151,7 +157,6 @@ export function useBoardColumnFeatures({
const { orderedFeatures } = resolveDependencies(map.backlog);
// Get all features to check blocking dependencies against
const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
@@ -161,7 +166,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, UIEvent, RefObject } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
@@ -64,6 +65,199 @@ interface KanbanBoardProps {
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<Item extends VirtualListItem> {
contentRef: RefObject<HTMLDivElement>;
onScroll: (event: UIEvent<HTMLDivElement>) => void;
itemIds: string[];
visibleItems: Item[];
totalHeight: number;
offsetTop: number;
startIndex: number;
shouldVirtualize: boolean;
registerItem: (id: string) => (node: HTMLDivElement | null) => void;
}
interface VirtualizedListProps<Item extends VirtualListItem> {
items: Item[];
isDragging: boolean;
estimatedItemHeight: number;
itemGap: number;
overscan: number;
virtualizationThreshold: number;
children: (state: VirtualListState<Item>) => 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<Item extends VirtualListItem>({
items,
isDragging,
estimatedItemHeight,
itemGap,
overscan,
virtualizationThreshold,
children,
}: VirtualizedListProps<Item>) {
const contentRef = useRef<HTMLDivElement>(null);
const measurementsRef = useRef<Map<string, number>>(new Map());
const scrollRafRef = useRef<number | null>(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<HTMLDivElement>) => {
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({
sensors,
collisionDetectionStrategy,
@@ -109,7 +303,7 @@ export function KanbanBoard({
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
const { keyboardShortcuts } = useAppStore();
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
@@ -135,213 +329,307 @@ export function KanbanBoard({
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
<VirtualizedList
key={column.id}
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
items={columnFeatures}
isDragging={isDragging}
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
itemGap={KANBAN_CARD_GAP_PX}
overscan={KANBAN_OVERSCAN_COUNT}
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
>
{({
contentRef,
onScroll,
itemIds,
visibleItems,
totalHeight,
offsetTop,
startIndex,
shouldVirtualize,
registerItem,
}) => (
<KanbanColumn
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => 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={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})}
</SortableContext>
</KanbanColumn>
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{shouldVirtualize ? (
<div className="relative" style={{ height: totalHeight }}>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{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 (
<div
key={feature.id}
ref={registerItem(feature.id)}
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
>
<KanbanCard
feature={feature}
onEdit={() => 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)
}
/>
</div>
);
})}
</div>
</div>
) : (
columnFeatures.map((feature, index) => {
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => 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)}
/>
);
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>