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