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
This commit is contained in:
Kacper
2026-01-04 22:24:03 +01:00
parent 4f3ac27534
commit 06c02de1cb
15 changed files with 840 additions and 15 deletions

View File

@@ -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<ReturnType<typeof useAppStore.getState>['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<Feature>) => {
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}
/>
</div>
{/* 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}
/>
) : (
<GraphView
@@ -1134,6 +1221,27 @@ export function BoardView() {
)}
</div>
{/* Selection Action Bar */}
{isSelectionMode && (
<SelectionActionBar
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)}
onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)}
/>
)}
{/* Mass Edit Dialog */}
<MassEditDialog
open={showMassEditDialog}
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
/>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}

View File

@@ -1,6 +1,15 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
import {
ImageIcon,
Archive,
Minimize2,
Square,
Maximize2,
Columns3,
Network,
CheckSquare,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
@@ -13,6 +22,8 @@ interface BoardControlsProps {
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => 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({
</Tooltip>
</div>
{/* Selection Mode Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isSelectionMode ? 'default' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className={cn('h-8 px-2', isSelectionMode && 'bg-brand-500 hover:bg-brand-600')}
data-testid="selection-mode-button"
>
<CheckSquare className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isSelectionMode ? 'Exit Select Mode' : 'Select Mode'}</p>
</TooltipContent>
</Tooltip>
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,2 +1,3 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';

View File

@@ -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 (
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
{isCurrentAutoTask && (

View File

@@ -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 (
<CardHeader className="p-3 pb-2 block">
{/* Running task header */}
{isCurrentAutoTask && (
{isCurrentAutoTask && !isSelectionMode && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
@@ -119,7 +121,7 @@ export function CardHeaderSection({
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
@@ -150,6 +152,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
!isSelectionMode &&
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">

View File

@@ -2,6 +2,7 @@ import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
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 { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
@@ -22,7 +23,12 @@ function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSPropert
return {};
}
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
function getCursorClass(
isOverlay: boolean | undefined,
isDraggable: boolean,
isSelectionMode: boolean
): string {
if (isSelectionMode) return 'cursor-pointer';
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
@@ -54,6 +60,10 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
}
export const KanbanCard = memo(function KanbanCard({
@@ -82,6 +92,9 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
@@ -95,13 +108,14 @@ export const KanbanCard = memo(function KanbanCard({
}, [isOverlay]);
const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
!isSelectionMode &&
(feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask));
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: feature.id,
disabled: !isDraggable || isOverlay,
disabled: !isDraggable || isOverlay || isSelectionMode,
});
const dndStyle = {
@@ -110,9 +124,12 @@ export const KanbanCard = memo(function KanbanCard({
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
// Only allow selection for backlog features
const isSelectable = isSelectionMode && feature.status === 'backlog';
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable),
getCursorClass(isOverlay, isDraggable, isSelectable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);
@@ -127,14 +144,24 @@ export const KanbanCard = memo(function KanbanCard({
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
isSelected && isSelectable && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background'
);
const handleCardClick = (e: React.MouseEvent) => {
if (isSelectable && onToggleSelect) {
e.preventDefault();
e.stopPropagation();
onToggleSelect();
}
};
const renderCardContent = () => (
<Card
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
onDoubleClick={isSelectionMode ? undefined : onEdit}
onClick={handleCardClick}
>
{/* Background overlay with opacity */}
{(!isDragging || isOverlay) && (
@@ -150,8 +177,16 @@ export const KanbanCard = memo(function KanbanCard({
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
{/* Category row with selection checkbox */}
<div className="px-3 pt-3 flex items-center gap-2">
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.()}
className="h-4 w-4 border-2 data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
)}
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
@@ -163,6 +198,7 @@ export const KanbanCard = memo(function KanbanCard({
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
isSelectionMode={isSelectionMode}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
@@ -187,6 +223,7 @@ export const KanbanCard = memo(function KanbanCard({
isCurrentAutoTask={!!isCurrentAutoTask}
hasContext={hasContext}
shortcutKey={shortcutKey}
isSelectionMode={isSelectionMode}
onEdit={onEdit}
onViewOutput={onViewOutput}
onVerify={onVerify}

View File

@@ -0,0 +1,78 @@
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onClear: () => void;
onSelectAll: () => void;
}
export function SelectionActionBar({
selectedCount,
totalCount,
onEdit,
onClear,
onSelectAll,
}: SelectionActionBarProps) {
if (selectedCount === 0) return null;
const allSelected = selectedCount === totalCount;
return (
<div
className={cn(
'fixed bottom-6 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-background/95 backdrop-blur-sm border border-border shadow-lg',
'animate-in slide-in-from-bottom-4 fade-in duration-200'
)}
data-testid="selection-action-bar"
>
<span className="text-sm font-medium text-foreground">
{selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected
</span>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
className="h-8 bg-brand-500 hover:bg-brand-600"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
{!allSelected && (
<Button
variant="outline"
size="sm"
onClick={onSelectAll}
className="h-8"
data-testid="selection-select-all-button"
>
<CheckSquare className="w-4 h-4 mr-1.5" />
Select All ({totalCount})
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 text-muted-foreground hover:text-foreground"
data-testid="selection-clear-button"
>
<X className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div>
);
}

View File

@@ -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';

View File

@@ -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<Feature>) => Promise<void>;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
}
interface ApplyState {
model: boolean;
thinkingLevel: boolean;
planningMode: boolean;
requirePlanApproval: boolean;
priority: boolean;
skipTests: boolean;
}
function getMixedValues(features: Feature[]): Record<string, boolean> {
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<T>(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 (
<div
className={cn(
'p-3 rounded-lg border transition-colors',
willApply ? 'border-brand-500/50 bg-brand-500/5' : 'border-border bg-muted/20'
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Checkbox
checked={willApply}
onCheckedChange={(checked) => onApplyChange(!!checked)}
className="data-[state=checked]:bg-brand-500 data-[state=checked]:border-brand-500"
/>
<Label
className="text-sm font-medium cursor-pointer"
onClick={() => onApplyChange(!willApply)}
>
{label}
</Label>
</div>
{isMixed && (
<span className="flex items-center gap-1 text-xs text-amber-500">
<AlertCircle className="w-3 h-3" />
Mixed values
</span>
)}
</div>
<div className={cn(!willApply && 'opacity-50 pointer-events-none')}>{children}</div>
</div>
);
}
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<ApplyState>({
model: false,
thinkingLevel: false,
planningMode: false,
requirePlanApproval: false,
priority: false,
skipTests: false,
});
// Field values
const [model, setModel] = useState<ModelAlias>('sonnet');
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
const [planningMode, setPlanningMode] = useState<PlanningMode>('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<Feature> = {};
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 (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl" data-testid="mass-edit-dialog">
<DialogHeader>
<DialogTitle>Edit {selectedFeatures.length} Features</DialogTitle>
<DialogDescription>
Select which settings to apply to all selected features.
</DialogDescription>
</DialogHeader>
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
data-testid="mass-edit-show-advanced-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Quick Select Profile</Label>
<p className="text-xs text-muted-foreground mb-2">
Selecting a profile will automatically enable model settings
</p>
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={model}
selectedThinkingLevel={thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? model : undefined}
onSelect={handleProfileSelect}
testIdPrefix="mass-edit-profile"
/>
</div>
)}
{/* Separator */}
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Model Selection */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<FieldWrapper
label="AI Model"
isMixed={mixedValues.model}
willApply={applyState.model}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, model: apply }))}
>
<ModelSelector
selectedModel={model}
onModelSelect={handleModelSelect}
testIdPrefix="mass-edit-model"
/>
</FieldWrapper>
{modelAllowsThinking && (
<FieldWrapper
label="Thinking Level"
isMixed={mixedValues.thinkingLevel}
willApply={applyState.thinkingLevel}
onApplyChange={(apply) =>
setApplyState((prev) => ({ ...prev, thinkingLevel: apply }))
}
>
<ThinkingLevelSelector
selectedLevel={thinkingLevel}
onLevelSelect={setThinkingLevel}
testIdPrefix="mass-edit-thinking"
/>
</FieldWrapper>
)}
</>
)}
{/* Separator before options */}
{(!showProfilesOnly || showAdvancedOptions) && <div className="border-t border-border" />}
{/* Planning Mode */}
<FieldWrapper
label="Planning Mode"
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
willApply={applyState.planningMode || applyState.requirePlanApproval}
onApplyChange={(apply) =>
setApplyState((prev) => ({
...prev,
planningMode: apply,
requirePlanApproval: apply,
}))
}
>
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription=""
testIdPrefix="mass-edit"
compact
/>
</FieldWrapper>
{/* Priority */}
<FieldWrapper
label="Priority"
isMixed={mixedValues.priority}
willApply={applyState.priority}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, priority: apply }))}
>
<PrioritySelector
selectedPriority={priority}
onPrioritySelect={setPriority}
testIdPrefix="mass-edit-priority"
/>
</FieldWrapper>
{/* Testing */}
<FieldWrapper
label="Testing"
isMixed={mixedValues.skipTests}
willApply={applyState.skipTests}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, skipTests: apply }))}
>
<TestingTabContent
skipTests={skipTests}
onSkipTestsChange={setSkipTests}
testIdPrefix="mass-edit"
/>
</FieldWrapper>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={isApplying}>
Cancel
</Button>
<Button
onClick={handleApply}
disabled={!hasAnyApply || isApplying}
loading={isApplying}
data-testid="mass-edit-apply-button"
>
Apply to {selectedFeatures.length} Features
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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';

View File

@@ -0,0 +1,82 @@
import { useState, useCallback, useEffect } from 'react';
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectedFeatureIds: Set<string>;
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<Set<string>>(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,
};
}

View File

@@ -50,6 +50,10 @@ interface KanbanBoardProps {
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectedFeatureIds?: Set<string>;
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)}
/>
);
})}