diff --git a/apps/server/src/routes/features/routes/bulk-delete.ts b/apps/server/src/routes/features/routes/bulk-delete.ts index 555515ae..851c288c 100644 --- a/apps/server/src/routes/features/routes/bulk-delete.ts +++ b/apps/server/src/routes/features/routes/bulk-delete.ts @@ -30,19 +30,27 @@ export function createBulkDeleteHandler(featureLoader: FeatureLoader) { return; } - const results = await Promise.all( - featureIds.map(async (featureId) => { - const success = await featureLoader.delete(projectPath, featureId); - if (success) { - return { featureId, success: true }; - } - return { - featureId, - success: false, - error: 'Deletion failed. Check server logs for details.', - }; - }) - ); + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + const results: BulkDeleteResult[] = []; + + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + const success = await featureLoader.delete(projectPath, featureId); + if (success) { + return { featureId, success: true }; + } + return { + featureId, + success: false, + error: 'Deletion failed. Check server logs for details.', + }; + }) + ); + results.push(...batchResults); + } const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); const failureCount = results.length - successCount; diff --git a/apps/server/src/routes/features/routes/bulk-update.ts b/apps/server/src/routes/features/routes/bulk-update.ts index a1c97e72..3fb8cc9f 100644 --- a/apps/server/src/routes/features/routes/bulk-update.ts +++ b/apps/server/src/routes/features/routes/bulk-update.ts @@ -43,17 +43,36 @@ export function createBulkUpdateHandler(featureLoader: FeatureLoader) { const results: BulkUpdateResult[] = []; const updatedFeatures: Feature[] = []; - for (const featureId of featureIds) { - try { - const updated = await featureLoader.update(projectPath, featureId, updates); - results.push({ featureId, success: true }); - updatedFeatures.push(updated); - } catch (error) { - results.push({ - featureId, - success: false, - error: getErrorMessage(error), - }); + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + try { + const updated = await featureLoader.update(projectPath, featureId, updates); + return { featureId, success: true as const, feature: updated }; + } catch (error) { + return { + featureId, + success: false as const, + error: getErrorMessage(error), + }; + } + }) + ); + + for (const result of batchResults) { + if (result.success) { + results.push({ featureId: result.featureId, success: true }); + updatedFeatures.push(result.feature); + } else { + results.push({ + featureId: result.featureId, + success: false, + error: result.error, + }); + } } } diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2dd705b9..20b9e258 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -187,6 +187,7 @@ export function BoardView() { // Selection mode hook for mass editing const { isSelectionMode, + selectionTarget, selectedFeatureIds, selectedCount, toggleSelectionMode, @@ -684,6 +685,67 @@ export function BoardView() { isPrimaryWorktreeBranch, ]); + // Get waiting_approval feature IDs in current branch for "Select All" + const allSelectableWaitingApprovalFeatureIds = useMemo(() => { + return hookFeatures + .filter((f) => { + // Only waiting_approval features + if (f.status !== 'waiting_approval') 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 bulk verifying multiple features + const handleBulkVerify = useCallback(async () => { + if (!currentProject || selectedFeatureIds.size === 0) return; + + try { + const api = getHttpApiClient(); + const featureIds = Array.from(selectedFeatureIds); + const updates = { status: 'verified' as const }; + + // Use bulk update API for efficient batch processing + const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates); + + if (result.success) { + // Update local state for all features + featureIds.forEach((featureId) => { + updateFeature(featureId, updates); + }); + toast.success(`Verified ${result.updatedCount} features`); + exitSelectionMode(); + } else { + toast.error('Failed to verify some features', { + description: `${result.failedCount} features failed to verify`, + }); + } + } catch (error) { + logger.error('Bulk verify failed:', error); + toast.error('Failed to verify features'); + } + }, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]); + // Handler for addressing PR comments - creates a feature and starts it automatically const handleAddressPRComments = useCallback( async (worktree: WorktreeInfo, prInfo: PRInfo) => { @@ -1448,6 +1510,7 @@ export function BoardView() { pipelineConfig={pipelineConfig} onOpenPipelineSettings={() => setShowPipelineSettings(true)} isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} selectedFeatureIds={selectedFeatureIds} onToggleFeatureSelection={toggleFeatureSelection} onToggleSelectionMode={toggleSelectionMode} @@ -1463,11 +1526,23 @@ export function BoardView() { {isSelectionMode && ( setShowMassEditDialog(true)} - onDelete={handleBulkDelete} + totalCount={ + selectionTarget === 'waiting_approval' + ? allSelectableWaitingApprovalFeatureIds.length + : allSelectableFeatureIds.length + } + onEdit={selectionTarget === 'backlog' ? () => setShowMassEditDialog(true) : undefined} + onDelete={selectionTarget === 'backlog' ? handleBulkDelete : undefined} + onVerify={selectionTarget === 'waiting_approval' ? handleBulkVerify : undefined} onClear={clearSelection} - onSelectAll={() => selectAll(allSelectableFeatureIds)} + onSelectAll={() => + selectAll( + selectionTarget === 'waiting_approval' + ? allSelectableWaitingApprovalFeatureIds + : allSelectableFeatureIds + ) + } + mode={selectionTarget === 'waiting_approval' ? 'waiting_approval' : 'backlog'} /> )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index 6f22e87e..ab640c21 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -65,6 +65,7 @@ interface KanbanCardProps { isSelectionMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; + selectionTarget?: 'backlog' | 'waiting_approval' | null; } export const KanbanCard = memo(function KanbanCard({ @@ -96,6 +97,7 @@ export const KanbanCard = memo(function KanbanCard({ isSelectionMode = false, isSelected = false, onToggleSelect, + selectionTarget = null, }: KanbanCardProps) { const { useWorktrees } = useAppStore(); const [isLifted, setIsLifted] = useState(false); @@ -125,8 +127,8 @@ export const KanbanCard = memo(function KanbanCard({ const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity); - // Only allow selection for backlog features - const isSelectable = isSelectionMode && feature.status === 'backlog'; + // Only allow selection for features matching the selection target + const isSelectable = isSelectionMode && feature.status === selectionTarget; const wrapperClasses = cn( 'relative select-none outline-none touch-none transition-transform duration-200 ease-out', @@ -180,7 +182,7 @@ export const KanbanCard = memo(function KanbanCard({ {/* Category row with selection checkbox */}
- {isSelectionMode && !isOverlay && feature.status === 'backlog' && ( + {isSelectable && !isOverlay && ( onToggleSelect?.()} diff --git a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx index 6982356a..58db5478 100644 --- a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx +++ b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react'; +import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Dialog, @@ -11,13 +11,17 @@ import { DialogTitle, } from '@/components/ui/dialog'; +export type SelectionActionMode = 'backlog' | 'waiting_approval'; + interface SelectionActionBarProps { selectedCount: number; totalCount: number; - onEdit: () => void; - onDelete: () => void; + onEdit?: () => void; + onDelete?: () => void; + onVerify?: () => void; onClear: () => void; onSelectAll: () => void; + mode?: SelectionActionMode; } export function SelectionActionBar({ @@ -25,10 +29,13 @@ export function SelectionActionBar({ totalCount, onEdit, onDelete, + onVerify, onClear, onSelectAll, + mode = 'backlog', }: SelectionActionBarProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showVerifyDialog, setShowVerifyDialog] = useState(false); const allSelected = selectedCount === totalCount && totalCount > 0; @@ -38,7 +45,16 @@ export function SelectionActionBar({ const handleConfirmDelete = () => { setShowDeleteDialog(false); - onDelete(); + onDelete?.(); + }; + + const handleVerifyClick = () => { + setShowVerifyDialog(true); + }; + + const handleConfirmVerify = () => { + setShowVerifyDialog(false); + onVerify?.(); }; return ( @@ -54,36 +70,56 @@ export function SelectionActionBar({ > {selectedCount === 0 - ? 'Select features to edit' + ? mode === 'waiting_approval' + ? 'Select features to verify' + : 'Select features to edit' : `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
- + {mode === 'backlog' && ( + <> + - + + + )} + + {mode === 'waiting_approval' && ( + + )} {!allSelected && ( + + + + ); } diff --git a/apps/ui/src/components/views/board-view/hooks/index.ts b/apps/ui/src/components/views/board-view/hooks/index.ts index 72e45677..c4c61a14 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects'; export { useBoardBackground } from './use-board-background'; export { useBoardPersistence } from './use-board-persistence'; export { useFollowUpState } from './use-follow-up-state'; -export { useSelectionMode } from './use-selection-mode'; +export { useSelectionMode, type SelectionTarget } from './use-selection-mode'; export { useListViewState } from './use-list-view-state'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts b/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts index 1470f447..e81dca10 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts @@ -1,10 +1,13 @@ import { useState, useCallback, useEffect } from 'react'; +export type SelectionTarget = 'backlog' | 'waiting_approval' | null; + interface UseSelectionModeReturn { isSelectionMode: boolean; + selectionTarget: SelectionTarget; selectedFeatureIds: Set; selectedCount: number; - toggleSelectionMode: () => void; + toggleSelectionMode: (target?: SelectionTarget) => void; toggleFeatureSelection: (featureId: string) => void; selectAll: (featureIds: string[]) => void; clearSelection: () => void; @@ -13,21 +16,26 @@ interface UseSelectionModeReturn { } export function useSelectionMode(): UseSelectionModeReturn { - const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectionTarget, setSelectionTarget] = useState(null); const [selectedFeatureIds, setSelectedFeatureIds] = useState>(new Set()); - const toggleSelectionMode = useCallback(() => { - setIsSelectionMode((prev) => { - if (prev) { + const isSelectionMode = selectionTarget !== null; + + const toggleSelectionMode = useCallback((target: SelectionTarget = 'backlog') => { + setSelectionTarget((prev) => { + if (prev === target) { // Exiting selection mode - clear selection setSelectedFeatureIds(new Set()); + return null; } - return !prev; + // Switching to a different target or entering selection mode + setSelectedFeatureIds(new Set()); + return target; }); }, []); const exitSelectionMode = useCallback(() => { - setIsSelectionMode(false); + setSelectionTarget(null); setSelectedFeatureIds(new Set()); }, []); @@ -70,6 +78,7 @@ export function useSelectionMode(): UseSelectionModeReturn { return { isSelectionMode, + selectionTarget, selectedFeatureIds, selectedCount: selectedFeatureIds.size, toggleSelectionMode, 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 c670ab70..6ace0e76 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -50,9 +50,10 @@ interface KanbanBoardProps { onOpenPipelineSettings?: () => void; // Selection mode props isSelectionMode?: boolean; + selectionTarget?: 'backlog' | 'waiting_approval' | null; selectedFeatureIds?: Set; onToggleFeatureSelection?: (featureId: string) => void; - onToggleSelectionMode?: () => void; + onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void; // Empty state action props onAiSuggest?: () => void; /** Whether currently dragging (hides empty states during drag) */ @@ -95,6 +96,7 @@ export function KanbanBoard({ pipelineConfig, onOpenPipelineSettings, isSelectionMode = false, + selectionTarget = null, selectedFeatureIds = new Set(), onToggleFeatureSelection, onToggleSelectionMode, @@ -189,12 +191,14 @@ export function KanbanBoard({
+ ) : column.id === 'waiting_approval' ? ( + ) : column.id === 'in_progress' ? (