Feat: Add ability to duplicate a feature and duplicate as a child

This commit is contained in:
eclipxe
2026-01-21 08:29:20 -08:00
committed by gsxdsm
parent 1662c6bf0b
commit e9802ac00c
9 changed files with 386 additions and 60 deletions

View File

@@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
return; return;
} }
// Check for duplicate title if title is provided
if (feature.title && feature.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${feature.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
const created = await featureLoader.create(projectPath, feature); const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks // Emit feature_created event for hooks

View File

@@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return; return;
} }
// Check for duplicate title if title is being updated
if (updates.title && updates.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(
projectPath,
updates.title,
featureId // Exclude the current feature from duplicate check
);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${updates.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}
// Get the current feature to detect status changes // Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId); const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined; const previousStatus = currentFeature?.status as FeatureStatus | undefined;

View File

@@ -590,6 +590,7 @@ export function BoardView() {
handleForceStopFeature, handleForceStopFeature,
handleStartNextFeatures, handleStartNextFeatures,
handleArchiveAllVerified, handleArchiveAllVerified,
handleDuplicateFeature,
} = useBoardActions({ } = useBoardActions({
currentProject, currentProject,
features: hookFeatures, features: hookFeatures,
@@ -1465,6 +1466,8 @@ export function BoardView() {
setSpawnParentFeature(feature); setSpawnParentFeature(feature);
setShowAddDialog(true); setShowAddDialog(true);
}, },
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
}} }}
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig} pipelineConfig={pipelineConfig}
@@ -1504,6 +1507,7 @@ export function BoardView() {
setSpawnParentFeature(feature); setSpawnParentFeature(feature);
setShowAddDialog(true); setShowAddDialog(true);
}} }}
onDuplicate={handleDuplicateFeature}
featuresWithContext={featuresWithContext} featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}

View File

@@ -8,6 +8,9 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { import {
@@ -19,6 +22,7 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
GitFork, GitFork,
Copy,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer'; import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -35,6 +39,8 @@ interface CardHeaderProps {
onDelete: () => void; onDelete: () => void;
onViewOutput?: () => void; onViewOutput?: () => void;
onSpawnTask?: () => void; onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
} }
export const CardHeaderSection = memo(function CardHeaderSection({ export const CardHeaderSection = memo(function CardHeaderSection({
@@ -46,6 +52,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onDelete, onDelete,
onViewOutput, onViewOutput,
onSpawnTask, onSpawnTask,
onDuplicate,
onDuplicateAsChild,
}: CardHeaderProps) { }: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -109,6 +117,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<GitFork className="w-3 h-3 mr-2" /> <GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task Spawn Sub-Task
</DropdownMenuItem> </DropdownMenuItem>
{onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
{onDuplicateAsChild && (
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{onDuplicateAsChild && (
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
{/* Model info in dropdown */} {/* Model info in dropdown */}
{(() => { {(() => {
const ProviderIcon = getProviderIconForModel(feature.model); const ProviderIcon = getProviderIconForModel(feature.model);
@@ -129,20 +170,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
{/* Backlog header */} {/* Backlog header */}
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && ( {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1"> <div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -153,6 +180,66 @@ export const CardHeaderSection = memo(function CardHeaderSection({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-backlog-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-backlog-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
{onDuplicateAsChild && (
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{onDuplicateAsChild && (
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)} )}
@@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
{onViewOutput && ( {onViewOutput && (
<Button <Button
variant="ghost" variant="ghost"
@@ -225,6 +296,70 @@ export const CardHeaderSection = memo(function CardHeaderSection({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
{onDuplicateAsChild && (
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{onDuplicateAsChild && (
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</> </>
)} )}
@@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<GitFork className="w-3 h-3 mr-2" /> <GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task Spawn Sub-Task
</DropdownMenuItem> </DropdownMenuItem>
{onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
{onDuplicateAsChild && (
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{onDuplicateAsChild && (
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
{/* Model info in dropdown */} {/* Model info in dropdown */}
{(() => { {(() => {
const ProviderIcon = getProviderIconForModel(feature.model); const ProviderIcon = getProviderIconForModel(feature.model);

View File

@@ -52,6 +52,8 @@ interface KanbanCardProps {
onViewPlan?: () => void; onViewPlan?: () => void;
onApprovePlan?: () => void; onApprovePlan?: () => void;
onSpawnTask?: () => void; onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
hasContext?: boolean; hasContext?: boolean;
isCurrentAutoTask?: boolean; isCurrentAutoTask?: boolean;
shortcutKey?: string; shortcutKey?: string;
@@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onSpawnTask, onSpawnTask,
onDuplicate,
onDuplicateAsChild,
hasContext, hasContext,
isCurrentAutoTask, isCurrentAutoTask,
shortcutKey, shortcutKey,
@@ -249,6 +253,8 @@ export const KanbanCard = memo(function KanbanCard({
onDelete={onDelete} onDelete={onDelete}
onViewOutput={onViewOutput} onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask} onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/> />
<CardContent className="px-3 pt-0 pb-0"> <CardContent className="px-3 pt-0 pb-0">

View File

@@ -42,6 +42,8 @@ export interface ListViewActionHandlers {
onViewPlan?: (feature: Feature) => void; onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void; onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
} }
export interface ListViewProps { export interface ListViewProps {
@@ -313,6 +315,18 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onSpawnTask?.(f); if (f) actionHandlers.onSpawnTask?.(f);
} }
: undefined, : undefined,
duplicate: actionHandlers.onDuplicate
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicate?.(f);
}
: undefined,
duplicateAsChild: actionHandlers.onDuplicateAsChild
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicateAsChild?.(f);
}
: undefined,
}); });
}, },
[actionHandlers, allFeatures] [actionHandlers, allFeatures]

View File

@@ -14,6 +14,7 @@ import {
GitBranch, GitBranch,
GitFork, GitFork,
ExternalLink, ExternalLink,
Copy,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -22,6 +23,9 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
@@ -43,6 +47,8 @@ export interface RowActionHandlers {
onViewPlan?: () => void; onViewPlan?: () => void;
onApprovePlan?: () => void; onApprovePlan?: () => void;
onSpawnTask?: () => void; onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
} }
export interface RowActionsProps { export interface RowActionsProps {
@@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)} onClick={withClose(handlers.onSpawnTask)}
/> />
)} )}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
@@ -457,6 +488,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)} onClick={withClose(handlers.onSpawnTask)}
/> />
)} )}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -503,6 +559,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)} onClick={withClose(handlers.onSpawnTask)}
/> />
)} )}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -554,6 +635,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)} onClick={withClose(handlers.onSpawnTask)}
/> />
)} )}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -581,6 +687,31 @@ export const RowActions = memo(function RowActions({
onClick={withClose(handlers.onSpawnTask)} onClick={withClose(handlers.onSpawnTask)}
/> />
)} )}
{handlers.onDuplicate && (
<DropdownMenuSub>
<div className="flex items-center">
<DropdownMenuItem
onClick={withClose(handlers.onDuplicate)}
className="flex-1 pr-0 rounded-r-none"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</DropdownMenuItem>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
)}
</div>
{handlers.onDuplicateAsChild && (
<DropdownMenuSubContent>
<MenuItem
icon={GitFork}
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
@@ -615,6 +746,8 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void; viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void; approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void; spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
} }
): RowActionHandlers { ): RowActionHandlers {
return { return {
@@ -631,5 +764,9 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined,
onDuplicateAsChild: actions.duplicateAsChild
? () => actions.duplicateAsChild!(featureId)
: undefined,
}; };
} }

View File

@@ -1083,6 +1083,26 @@ export function useBoardActions({
}); });
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
const handleDuplicateFeature = useCallback(
async (feature: Feature, asChild: boolean = false) => {
// Copy all feature data, only override id/status (handled by create) and dependencies if as child
const { id: _id, status: _status, ...featureData } = feature;
const duplicatedFeatureData = {
...featureData,
// If duplicating as child, set source as dependency; otherwise keep existing
...(asChild && { dependencies: [feature.id] }),
};
// Reuse the existing handleAddFeature logic
await handleAddFeature(duplicatedFeatureData);
toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', {
description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`,
});
},
[handleAddFeature]
);
return { return {
handleAddFeature, handleAddFeature,
handleUpdateFeature, handleUpdateFeature,
@@ -1103,5 +1123,6 @@ export function useBoardActions({
handleForceStopFeature, handleForceStopFeature,
handleStartNextFeatures, handleStartNextFeatures,
handleArchiveAllVerified, handleArchiveAllVerified,
handleDuplicateFeature,
}; };
} }

View File

@@ -46,6 +46,7 @@ interface KanbanBoardProps {
onViewPlan: (feature: Feature) => void; onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature, asChild: boolean) => void;
featuresWithContext: Set<string>; featuresWithContext: Set<string>;
runningAutoTasks: string[]; runningAutoTasks: string[];
onArchiveAllVerified: () => void; onArchiveAllVerified: () => void;
@@ -282,6 +283,7 @@ export function KanbanBoard({
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onSpawnTask, onSpawnTask,
onDuplicate,
featuresWithContext, featuresWithContext,
runningAutoTasks, runningAutoTasks,
onArchiveAllVerified, onArchiveAllVerified,
@@ -569,6 +571,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)} onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)} onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)} onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature, false)}
onDuplicateAsChild={() => onDuplicate?.(feature, true)}
hasContext={featuresWithContext.has(feature.id)} hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}
@@ -611,6 +615,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)} onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)} onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)} onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature, false)}
onDuplicateAsChild={() => onDuplicate?.(feature, true)}
hasContext={featuresWithContext.has(feature.id)} hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}