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 && (
+
+
+
+ )}
+
+ )}