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

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

View File

@@ -8,6 +8,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
@@ -19,6 +22,7 @@ import {
ChevronDown,
ChevronUp,
GitFork,
Copy,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -35,6 +39,8 @@ interface CardHeaderProps {
onDelete: () => void;
onViewOutput?: () => void;
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
}
export const CardHeaderSection = memo(function CardHeaderSection({
@@ -46,6 +52,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onDelete,
onViewOutput,
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = 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" />
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>
)}
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
@@ -129,20 +170,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
{/* Backlog header */}
{!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
<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
variant="ghost"
size="sm"
@@ -153,6 +180,66 @@ export const CardHeaderSection = memo(function CardHeaderSection({
>
<Trash2 className="w-4 h-4" />
</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>
)}
@@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({
>
<Edit className="w-4 h-4" />
</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 && (
<Button
variant="ghost"
@@ -225,6 +296,70 @@ export const CardHeaderSection = memo(function CardHeaderSection({
>
<Trash2 className="w-4 h-4" />
</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>
</>
)}
@@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<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>
)}
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);

View File

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

View File

@@ -42,6 +42,8 @@ export interface ListViewActionHandlers {
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
}
export interface ListViewProps {
@@ -313,6 +315,18 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onSpawnTask?.(f);
}
: 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]

View File

@@ -14,6 +14,7 @@ import {
GitBranch,
GitFork,
ExternalLink,
Copy,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -22,6 +23,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store';
@@ -43,6 +47,8 @@ export interface RowActionHandlers {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
}
export interface RowActionsProps {
@@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({
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 />
<MenuItem
icon={Trash2}
@@ -457,6 +488,31 @@ export const RowActions = memo(function RowActions({
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
icon={Trash2}
label="Delete"
@@ -503,6 +559,31 @@ export const RowActions = memo(function RowActions({
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
icon={Trash2}
label="Delete"
@@ -554,6 +635,31 @@ export const RowActions = memo(function RowActions({
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
icon={Trash2}
label="Delete"
@@ -581,6 +687,31 @@ export const RowActions = memo(function RowActions({
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 />
<MenuItem
icon={Trash2}
@@ -615,6 +746,8 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -631,5 +764,9 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(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]);
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 {
handleAddFeature,
handleUpdateFeature,
@@ -1103,5 +1123,6 @@ export function useBoardActions({
handleForceStopFeature,
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
};
}

View File

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