diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d075..c607e72e 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event 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); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1..4d5e7a00 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { 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 const currentFeature = await featureLoader.get(projectPath, featureId); const previousStatus = currentFeature?.status as FeatureStatus | undefined; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index d8be006d..1266ea77 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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)} 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 793c3191..e3575c55 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 @@ -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({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* 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' && (
- + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-backlog-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a332f305..4859331f 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -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} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 0a08b127..cac687eb 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -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] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index bb5c53d1..60158d0f 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -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 && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} 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, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index ebd80591..4f3c0517 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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, }; } 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 7f857392..ef091872 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -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; 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}