feat: implement bulk feature verification and enhance selection mode

- Added functionality for bulk verifying features in the BoardView, allowing users to mark multiple features as verified at once.
- Introduced a selection target mechanism to differentiate between 'backlog' and 'waiting_approval' features during selection mode.
- Updated the KanbanCard and SelectionActionBar components to support the new selection target logic, improving user experience for bulk actions.
- Enhanced the UI to provide appropriate actions based on the current selection target, including verification options for waiting approval features.
This commit is contained in:
webdevcody
2026-01-15 13:14:15 -05:00
parent 241fd0b252
commit 06b047cfcb
10 changed files with 348 additions and 73 deletions

View File

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

View File

@@ -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,
});
}
}
}

View File

@@ -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 && (
<SelectionActionBar
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => 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'}
/>
)}

View File

@@ -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 */}
<div className="px-3 pt-3 flex items-center gap-2">
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
{isSelectable && !isOverlay && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.()}

View File

@@ -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({
>
<span className="text-sm font-medium text-foreground">
{selectedCount === 0
? 'Select features to edit'
? mode === 'waiting_approval'
? 'Select features to verify'
: 'Select features to edit'
: `${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}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
{mode === 'backlog' && (
<>
<Button
variant="default"
size="sm"
onClick={onEdit}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
</>
)}
{mode === 'waiting_approval' && (
<Button
variant="default"
size="sm"
onClick={handleVerifyClick}
disabled={selectedCount === 0}
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
data-testid="selection-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-1.5" />
Verify Selected
</Button>
)}
{!allSelected && (
<Button
@@ -146,6 +182,42 @@ export function SelectionActionBar({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Verify Confirmation Dialog */}
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
<DialogContent data-testid="bulk-verify-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="w-5 h-5" />
Verify Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to mark {selectedCount} feature
{selectedCount !== 1 ? 's' : ''} as verified?
<span className="block mt-2 text-muted-foreground">
This will move them to the Verified column.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowVerifyDialog(false)}
data-testid="cancel-bulk-verify-button"
>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleConfirmVerify}
data-testid="confirm-bulk-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Verify
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

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

View File

@@ -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<string>;
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<SelectionTarget>(null);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(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,

View File

@@ -50,9 +50,10 @@ interface KanbanBoardProps {
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectionTarget?: 'backlog' | 'waiting_approval' | null;
selectedFeatureIds?: Set<string>;
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({
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
@@ -207,6 +211,31 @@ export function KanbanBoard({
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
@@ -305,6 +334,7 @@ export function KanbanBoard({
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>

View File

@@ -43,11 +43,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: `cursor-${id}`,
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,

View File

@@ -22,6 +22,10 @@ export type CursorModelId =
| 'cursor-gpt-5.1-codex-high' // GPT-5.1 Codex High via Cursor
| 'cursor-gpt-5.1-codex-max' // GPT-5.1 Codex Max via Cursor
| 'cursor-gpt-5.1-codex-max-high' // GPT-5.1 Codex Max High via Cursor
| 'cursor-gpt-5.2-codex' // GPT-5.2 Codex via Cursor
| 'cursor-gpt-5.2-codex-high' // GPT-5.2 Codex High via Cursor
| 'cursor-gpt-5.2-codex-max' // GPT-5.2 Codex Max via Cursor
| 'cursor-gpt-5.2-codex-max-high' // GPT-5.2 Codex Max High via Cursor
| 'grok'; // Grok
/**
@@ -159,6 +163,34 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
hasThinking: false,
supportsVision: false,
},
'cursor-gpt-5.2-codex': {
id: 'cursor-gpt-5.2-codex',
label: 'GPT-5.2 Codex',
description: 'OpenAI GPT-5.2 Codex for code generation',
hasThinking: false,
supportsVision: false,
},
'cursor-gpt-5.2-codex-high': {
id: 'cursor-gpt-5.2-codex-high',
label: 'GPT-5.2 Codex High',
description: 'OpenAI GPT-5.2 Codex with high compute',
hasThinking: false,
supportsVision: false,
},
'cursor-gpt-5.2-codex-max': {
id: 'cursor-gpt-5.2-codex-max',
label: 'GPT-5.2 Codex Max',
description: 'OpenAI GPT-5.2 Codex Max capacity',
hasThinking: false,
supportsVision: false,
},
'cursor-gpt-5.2-codex-max-high': {
id: 'cursor-gpt-5.2-codex-max-high',
label: 'GPT-5.2 Codex Max High',
description: 'OpenAI GPT-5.2 Codex Max with high compute',
hasThinking: false,
supportsVision: false,
},
grok: {
id: 'grok',
label: 'Grok',
@@ -284,6 +316,34 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
],
},
// GPT-5.2 Codex group (capacity + compute matrix)
{
baseId: 'cursor-gpt-5.2-codex-group',
label: 'GPT-5.2 Codex',
description: 'OpenAI GPT-5.2 Codex for code generation',
variantType: 'capacity',
variants: [
{ id: 'cursor-gpt-5.2-codex', label: 'Standard', description: 'Default capacity' },
{
id: 'cursor-gpt-5.2-codex-high',
label: 'High',
description: 'High compute',
badge: 'Compute',
},
{
id: 'cursor-gpt-5.2-codex-max',
label: 'Max',
description: 'Maximum capacity',
badge: 'Capacity',
},
{
id: 'cursor-gpt-5.2-codex-max-high',
label: 'Max High',
description: 'Max capacity + high compute',
badge: 'Premium',
},
],
},
// Sonnet 4.5 group (thinking mode)
{
baseId: 'sonnet-4.5-group',