From 06c02de1cbcb18d2f033bdb8f68fa094c93ab96f Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 4 Jan 2026 22:24:03 +0100 Subject: [PATCH] feat: add mass edit feature for backlog kanban cards Add ability to select multiple backlog features and edit their configuration in bulk. Selection is limited to backlog column features in the current branch/worktree only. Changes: - Add selection mode toggle in board controls - Add checkbox selection on kanban cards (backlog only) - Disable drag and drop during selection mode - Hide action buttons during selection mode - Add floating selection action bar with Edit/Clear/Select All - Add mass edit dialog with all configuration options in single scroll view - Add server endpoint for bulk feature updates --- apps/server/src/routes/features/index.ts | 6 + .../src/routes/features/routes/bulk-update.ts | 75 ++++ apps/ui/src/components/views/board-view.tsx | 108 +++++ .../views/board-view/board-controls.tsx | 33 +- .../views/board-view/components/index.ts | 1 + .../components/kanban-card/card-actions.tsx | 7 + .../components/kanban-card/card-header.tsx | 7 +- .../components/kanban-card/kanban-card.tsx | 59 ++- .../components/selection-action-bar.tsx | 78 ++++ .../views/board-view/dialogs/index.ts | 1 + .../board-view/dialogs/mass-edit-dialog.tsx | 370 ++++++++++++++++++ .../views/board-view/hooks/index.ts | 1 + .../board-view/hooks/use-selection-mode.ts | 82 ++++ .../views/board-view/kanban-board.tsx | 10 + apps/ui/src/lib/http-api-client.ts | 17 +- 15 files changed, 840 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/routes/features/routes/bulk-update.ts create mode 100644 apps/ui/src/components/views/board-view/components/selection-action-bar.tsx create mode 100644 apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx create mode 100644 apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 8cb287d1..4f62ee17 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -9,6 +9,7 @@ import { createListHandler } from './routes/list.js'; import { createGetHandler } from './routes/get.js'; import { createCreateHandler } from './routes/create.js'; import { createUpdateHandler } from './routes/update.js'; +import { createBulkUpdateHandler } from './routes/bulk-update.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; @@ -20,6 +21,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader)); router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); + router.post( + '/bulk-update', + validatePathParams('projectPath'), + createBulkUpdateHandler(featureLoader) + ); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); diff --git a/apps/server/src/routes/features/routes/bulk-update.ts b/apps/server/src/routes/features/routes/bulk-update.ts new file mode 100644 index 00000000..a1c97e72 --- /dev/null +++ b/apps/server/src/routes/features/routes/bulk-update.ts @@ -0,0 +1,75 @@ +/** + * POST /bulk-update endpoint - Update multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { Feature } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkUpdateRequest { + projectPath: string; + featureIds: string[]; + updates: Partial; +} + +interface BulkUpdateResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkUpdateHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + if (!updates || Object.keys(updates).length === 0) { + res.status(400).json({ + success: false, + error: 'updates object with at least one field is required', + }); + return; + } + + 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), + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + res.json({ + success: failureCount === 0, + updatedCount: successCount, + failedCount: failureCount, + results, + features: updatedFeatures, + }); + } catch (error) { + logError(error, 'Bulk update features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f59ccbe6..1dc99b05 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -56,7 +56,10 @@ import { useBoardBackground, useBoardPersistence, useFollowUpState, + useSelectionMode, } from './board-view/hooks'; +import { SelectionActionBar } from './board-view/components'; +import { MassEditDialog } from './board-view/dialogs'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -154,6 +157,19 @@ export function BoardView() { handleFollowUpDialogChange, } = 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 @@ -447,6 +463,72 @@ export function BoardView() { 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] + ); + + // 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) => { @@ -1069,6 +1151,8 @@ export function BoardView() { onDetailLevelChange={setKanbanCardDetailLevel} boardViewMode={boardViewMode} onBoardViewModeChange={setBoardViewMode} + isSelectionMode={isSelectionMode} + onToggleSelectionMode={toggleSelectionMode} /> {/* View Content - Kanban or Graph */} @@ -1109,6 +1193,9 @@ export function BoardView() { currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null } onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} /> ) : ( + {/* Selection Action Bar */} + {isSelectionMode && ( + setShowMassEditDialog(true)} + onClear={clearSelection} + onSelectAll={() => selectAll(allSelectableFeatureIds)} + /> + )} + + {/* Mass Edit Dialog */} + setShowMassEditDialog(false)} + selectedFeatures={selectedFeatures} + onApply={handleBulkUpdate} + showProfilesOnly={showProfilesOnly} + aiProfiles={aiProfiles} + /> + {/* Board Background Modal */} void; boardViewMode: BoardViewMode; onBoardViewModeChange: (mode: BoardViewMode) => void; + isSelectionMode?: boolean; + onToggleSelectionMode?: () => void; } export function BoardControls({ @@ -24,6 +35,8 @@ export function BoardControls({ onDetailLevelChange, boardViewMode, onBoardViewModeChange, + isSelectionMode = false, + onToggleSelectionMode, }: BoardControlsProps) { if (!isMounted) return null; @@ -75,6 +88,24 @@ export function BoardControls({ + {/* Selection Mode Toggle */} + + + + + +

{isSelectionMode ? 'Exit Select Mode' : 'Select Mode'}

+
+
+ {/* Board Background Button */} diff --git a/apps/ui/src/components/views/board-view/components/index.ts b/apps/ui/src/components/views/board-view/components/index.ts index c8760520..514e407d 100644 --- a/apps/ui/src/components/views/board-view/components/index.ts +++ b/apps/ui/src/components/views/board-view/components/index.ts @@ -1,2 +1,3 @@ export { KanbanCard } from './kanban-card/kanban-card'; export { KanbanColumn } from './kanban-column'; +export { SelectionActionBar } from './selection-action-bar'; diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index b791216b..f9ac0d65 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -17,6 +17,7 @@ interface CardActionsProps { isCurrentAutoTask: boolean; hasContext?: boolean; shortcutKey?: string; + isSelectionMode?: boolean; onEdit: () => void; onViewOutput?: () => void; onVerify?: () => void; @@ -35,6 +36,7 @@ export function CardActions({ isCurrentAutoTask, hasContext, shortcutKey, + isSelectionMode = false, onEdit, onViewOutput, onVerify, @@ -47,6 +49,11 @@ export function CardActions({ onViewPlan, onApprovePlan, }: CardActionsProps) { + // Hide all actions when in selection mode + if (isSelectionMode) { + return null; + } + return (
{isCurrentAutoTask && ( diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 6f486caa..b48f78a3 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -29,6 +29,7 @@ interface CardHeaderProps { feature: Feature; isDraggable: boolean; isCurrentAutoTask: boolean; + isSelectionMode?: boolean; onEdit: () => void; onDelete: () => void; onViewOutput?: () => void; @@ -39,6 +40,7 @@ export function CardHeaderSection({ feature, isDraggable, isCurrentAutoTask, + isSelectionMode = false, onEdit, onDelete, onViewOutput, @@ -59,7 +61,7 @@ export function CardHeaderSection({ return ( {/* Running task header */} - {isCurrentAutoTask && ( + {isCurrentAutoTask && !isSelectionMode && (
@@ -119,7 +121,7 @@ export function CardHeaderSection({ )} {/* Backlog header */} - {!isCurrentAutoTask && feature.status === 'backlog' && ( + {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
+ + {!allSelected && ( + + )} + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 6979f9d4..b8d5aa30 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -7,3 +7,4 @@ export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog' export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog } from './follow-up-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; +export { MassEditDialog } from './mass-edit-dialog'; diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx new file mode 100644 index 00000000..1b4b1467 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Settings2, AlertCircle } from 'lucide-react'; +import { modelSupportsThinking } from '@/lib/utils'; +import { Feature, ModelAlias, ThinkingLevel, AIProfile, PlanningMode } from '@/store/app-store'; +import { + ModelSelector, + ThinkingLevelSelector, + ProfileQuickSelect, + TestingTabContent, + PrioritySelector, + PlanningModeSelector, +} from '../shared'; +import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +interface MassEditDialogProps { + open: boolean; + onClose: () => void; + selectedFeatures: Feature[]; + onApply: (updates: Partial) => Promise; + showProfilesOnly: boolean; + aiProfiles: AIProfile[]; +} + +interface ApplyState { + model: boolean; + thinkingLevel: boolean; + planningMode: boolean; + requirePlanApproval: boolean; + priority: boolean; + skipTests: boolean; +} + +function getMixedValues(features: Feature[]): Record { + if (features.length === 0) return {}; + const first = features[0]; + return { + model: !features.every((f) => f.model === first.model), + thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel), + planningMode: !features.every((f) => f.planningMode === first.planningMode), + requirePlanApproval: !features.every( + (f) => f.requirePlanApproval === first.requirePlanApproval + ), + priority: !features.every((f) => f.priority === first.priority), + skipTests: !features.every((f) => f.skipTests === first.skipTests), + }; +} + +function getInitialValue(features: Feature[], key: keyof Feature, defaultValue: T): T { + if (features.length === 0) return defaultValue; + return (features[0][key] as T) ?? defaultValue; +} + +interface FieldWrapperProps { + label: string; + isMixed: boolean; + willApply: boolean; + onApplyChange: (apply: boolean) => void; + children: React.ReactNode; +} + +function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: FieldWrapperProps) { + return ( +
+
+
+ onApplyChange(!!checked)} + className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500" + /> + +
+ {isMixed && ( + + + Mixed values + + )} +
+
{children}
+
+ ); +} + +export function MassEditDialog({ + open, + onClose, + selectedFeatures, + onApply, + showProfilesOnly, + aiProfiles, +}: MassEditDialogProps) { + const [isApplying, setIsApplying] = useState(false); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + // Track which fields to apply + const [applyState, setApplyState] = useState({ + model: false, + thinkingLevel: false, + planningMode: false, + requirePlanApproval: false, + priority: false, + skipTests: false, + }); + + // Field values + const [model, setModel] = useState('sonnet'); + const [thinkingLevel, setThinkingLevel] = useState('none'); + const [planningMode, setPlanningMode] = useState('skip'); + const [requirePlanApproval, setRequirePlanApproval] = useState(false); + const [priority, setPriority] = useState(2); + const [skipTests, setSkipTests] = useState(false); + + // Calculate mixed values + const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); + + // Reset state when dialog opens with new features + useEffect(() => { + if (open && selectedFeatures.length > 0) { + setApplyState({ + model: false, + thinkingLevel: false, + planningMode: false, + requirePlanApproval: false, + priority: false, + skipTests: false, + }); + setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias); + setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); + setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode); + setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false)); + setPriority(getInitialValue(selectedFeatures, 'priority', 2)); + setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false)); + setShowAdvancedOptions(false); + } + }, [open, selectedFeatures]); + + const handleModelSelect = (newModel: string) => { + const isCursor = isCursorModel(newModel); + setModel(newModel as ModelAlias); + if (isCursor || !modelSupportsThinking(newModel)) { + setThinkingLevel('none'); + } + }; + + const handleProfileSelect = (profile: AIProfile) => { + if (profile.provider === 'cursor') { + const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; + setModel(cursorModel as ModelAlias); + setThinkingLevel('none'); + } else { + setModel((profile.model || 'sonnet') as ModelAlias); + setThinkingLevel(profile.thinkingLevel || 'none'); + } + setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true })); + }; + + const handleApply = async () => { + const updates: Partial = {}; + + if (applyState.model) updates.model = model; + if (applyState.thinkingLevel) updates.thinkingLevel = thinkingLevel; + if (applyState.planningMode) updates.planningMode = planningMode; + if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval; + if (applyState.priority) updates.priority = priority; + if (applyState.skipTests) updates.skipTests = skipTests; + + if (Object.keys(updates).length === 0) { + onClose(); + return; + } + + setIsApplying(true); + try { + await onApply(updates); + onClose(); + } finally { + setIsApplying(false); + } + }; + + const hasAnyApply = Object.values(applyState).some(Boolean); + const isCurrentModelCursor = isCursorModel(model); + const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model); + + return ( + !open && onClose()}> + + + Edit {selectedFeatures.length} Features + + Select which settings to apply to all selected features. + + + +
+ {/* Show Advanced Options Toggle */} + {showProfilesOnly && ( +
+
+

Simple Mode Active

+

+ Only showing AI profiles. Advanced model tweaking is hidden. +

+
+ +
+ )} + + {/* Quick Select Profile Section */} + {aiProfiles.length > 0 && ( +
+ +

+ Selecting a profile will automatically enable model settings +

+ +
+ )} + + {/* Separator */} + {aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && ( +
+ )} + + {/* Model Selection */} + {(!showProfilesOnly || showAdvancedOptions) && ( + <> + setApplyState((prev) => ({ ...prev, model: apply }))} + > + + + + {modelAllowsThinking && ( + + setApplyState((prev) => ({ ...prev, thinkingLevel: apply })) + } + > + + + )} + + )} + + {/* Separator before options */} + {(!showProfilesOnly || showAdvancedOptions) &&
} + + {/* Planning Mode */} + + setApplyState((prev) => ({ + ...prev, + planningMode: apply, + requirePlanApproval: apply, + })) + } + > + + + + {/* Priority */} + setApplyState((prev) => ({ ...prev, priority: apply }))} + > + + + + {/* Testing */} + setApplyState((prev) => ({ ...prev, skipTests: apply }))} + > + + +
+ + + + + + +
+ ); +} 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 9b855b06..272937f4 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -7,3 +7,4 @@ 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'; 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 new file mode 100644 index 00000000..1470f447 --- /dev/null +++ b/apps/ui/src/components/views/board-view/hooks/use-selection-mode.ts @@ -0,0 +1,82 @@ +import { useState, useCallback, useEffect } from 'react'; + +interface UseSelectionModeReturn { + isSelectionMode: boolean; + selectedFeatureIds: Set; + selectedCount: number; + toggleSelectionMode: () => void; + toggleFeatureSelection: (featureId: string) => void; + selectAll: (featureIds: string[]) => void; + clearSelection: () => void; + isFeatureSelected: (featureId: string) => boolean; + exitSelectionMode: () => void; +} + +export function useSelectionMode(): UseSelectionModeReturn { + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedFeatureIds, setSelectedFeatureIds] = useState>(new Set()); + + const toggleSelectionMode = useCallback(() => { + setIsSelectionMode((prev) => { + if (prev) { + // Exiting selection mode - clear selection + setSelectedFeatureIds(new Set()); + } + return !prev; + }); + }, []); + + const exitSelectionMode = useCallback(() => { + setIsSelectionMode(false); + setSelectedFeatureIds(new Set()); + }, []); + + const toggleFeatureSelection = useCallback((featureId: string) => { + setSelectedFeatureIds((prev) => { + const next = new Set(prev); + if (next.has(featureId)) { + next.delete(featureId); + } else { + next.add(featureId); + } + return next; + }); + }, []); + + const selectAll = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(new Set(featureIds)); + }, []); + + const clearSelection = useCallback(() => { + setSelectedFeatureIds(new Set()); + }, []); + + const isFeatureSelected = useCallback( + (featureId: string) => selectedFeatureIds.has(featureId), + [selectedFeatureIds] + ); + + // Handle Escape key to exit selection mode + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isSelectionMode) { + exitSelectionMode(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSelectionMode, exitSelectionMode]); + + return { + isSelectionMode, + selectedFeatureIds, + selectedCount: selectedFeatureIds.size, + toggleSelectionMode, + toggleFeatureSelection, + selectAll, + clearSelection, + isFeatureSelected, + exitSelectionMode, + }; +} 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 c21711b9..5cef1aa9 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -50,6 +50,10 @@ interface KanbanBoardProps { onArchiveAllVerified: () => void; pipelineConfig: PipelineConfig | null; onOpenPipelineSettings?: () => void; + // Selection mode props + isSelectionMode?: boolean; + selectedFeatureIds?: Set; + onToggleFeatureSelection?: (featureId: string) => void; } export function KanbanBoard({ @@ -83,6 +87,9 @@ export function KanbanBoard({ onArchiveAllVerified, pipelineConfig, onOpenPipelineSettings, + isSelectionMode = false, + selectedFeatureIds = new Set(), + onToggleFeatureSelection, }: KanbanBoardProps) { // Generate columns including pipeline steps const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); @@ -200,6 +207,9 @@ export function KanbanBoard({ glassmorphism={backgroundSettings.cardGlassmorphism} cardBorderEnabled={backgroundSettings.cardBorderEnabled} cardBorderOpacity={backgroundSettings.cardBorderOpacity} + isSelectionMode={isSelectionMode} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} /> ); })} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 75c1e6c5..2fad2389 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1147,7 +1147,20 @@ export class HttpApiClient implements ElectronAPI { }; // Features API - features: FeaturesAPI = { + features: FeaturesAPI & { + bulkUpdate: ( + projectPath: string, + featureIds: string[], + updates: Partial + ) => Promise<{ + success: boolean; + updatedCount?: number; + failedCount?: number; + results?: Array<{ featureId: string; success: boolean; error?: string }>; + features?: Feature[]; + error?: string; + }>; + } = { getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }), get: (projectPath: string, featureId: string) => this.post('/api/features/get', { projectPath, featureId }), @@ -1161,6 +1174,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/features/agent-output', { projectPath, featureId }), generateTitle: (description: string) => this.post('/api/features/generate-title', { description }), + bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) => + this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), }; // Auto Mode API