Merge pull request #774 from gsxdsm/feat/duplicate-festure

Feat: Add ability to duplicate a feature and duplicate as a child
This commit is contained in:
gsxdsm
2026-02-17 10:43:04 -08:00
committed by GitHub
24 changed files with 542 additions and 107 deletions

View File

@@ -14,10 +14,6 @@
**Stop typing code. Start directing AI agents.** **Stop typing code. Start directing AI agents.**
> **[!WARNING]**
>
> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed.
<details open> <details open>
<summary><h2>Table of Contents</h2></summary> <summary><h2>Table of Contents</h2></summary>

View File

@@ -0,0 +1,74 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
const eslintConfig = defineConfig([
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
// Node.js globals
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
fetch: 'readonly',
Response: 'readonly',
Request: 'readonly',
Headers: 'readonly',
FormData: 'readonly',
RequestInit: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
queueMicrotask: 'readonly',
// Node.js types
NodeJS: 'readonly',
},
},
plugins: {
'@typescript-eslint': ts,
},
rules: {
...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': 'warn',
// Server code frequently works with terminal output containing ANSI escape codes
'no-control-regex': 'off',
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-nocheck': 'allow-with-description',
minimumDescriptionLength: 10,
},
],
},
},
globalIgnores(['dist/**', 'node_modules/**']),
]);
export default eslintConfig;

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

@@ -101,7 +101,12 @@ export function createWorktreeRoutes(
requireValidWorktree, requireValidWorktree,
createPullHandler() createPullHandler()
); );
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler()); router.post(
'/checkout-branch',
validatePathParams('worktreePath'),
requireValidWorktree,
createCheckoutBranchHandler()
);
router.post( router.post(
'/list-branches', '/list-branches',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),

View File

@@ -2,15 +2,15 @@
* POST /checkout-branch endpoint - Create and checkout a new branch * POST /checkout-branch endpoint - Create and checkout a new branch
* *
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts * the requireValidWorktree middleware in index.ts.
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
* middleware in index.ts.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import path from 'path';
import { promisify } from 'util'; import { stat } from 'fs/promises';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
const execAsync = promisify(exec);
export function createCheckoutBranchHandler() { export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() {
return; return;
} }
// Validate branch name (basic validation) // Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/
const invalidChars = /[\s~^:?*\[\\]/; if (!isValidBranchName(branchName)) {
if (invalidChars.test(branchName)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Branch name contains invalid characters', error:
'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
}); });
return; return;
} }
// Get current branch for reference // Resolve and validate worktreePath to prevent traversal attacks.
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { // The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
cwd: worktreePath, // but we also resolve the path and verify it exists as a directory.
}); const resolvedPath = path.resolve(worktreePath);
try {
const stats = await stat(resolvedPath);
if (!stats.isDirectory()) {
res.status(400).json({
success: false,
error: 'worktreePath is not a directory',
});
return;
}
} catch {
res.status(400).json({
success: false,
error: 'worktreePath does not exist or is not accessible',
});
return;
}
// Get current branch for reference (using argument array to avoid shell injection)
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath
);
const currentBranch = currentBranchOutput.trim(); const currentBranch = currentBranchOutput.trim();
// Check if branch already exists // Check if branch already exists
try { try {
await execAsync(`git rev-parse --verify ${branchName}`, { await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
cwd: worktreePath,
});
// Branch exists // Branch exists
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() {
// Branch doesn't exist, good to create // Branch doesn't exist, good to create
} }
// Create and checkout the new branch // Create and checkout the new branch (using argument array to avoid shell injection)
await execAsync(`git checkout -b ${branchName}`, { await execGitCommand(['checkout', '-b', branchName], resolvedPath);
cwd: worktreePath,
});
res.json({ res.json({
success: true, success: true,

View File

@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
); );
try { const result = await openInFileManager(worktreePath);
const result = await openInFileManager(worktreePath); res.json({
res.json({ success: true,
success: true, result: {
result: { message: `Opened ${worktreePath} in ${result.editorName}`,
message: `Opened ${worktreePath} in ${result.editorName}`, editorName: result.editorName,
editorName: result.editorName, },
}, });
});
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
}
} }
} catch (error) { } catch (error) {
logError(error, 'Open in editor failed'); logError(error, 'Open in editor failed');

View File

@@ -662,7 +662,7 @@ export class ClaudeUsageService {
resetTime = this.parseResetTime(resetText, type); resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text // Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim();
} }
return { percentage: percentage ?? 0, resetTime, resetText }; return { percentage: percentage ?? 0, resetTime, resetText };

View File

@@ -124,7 +124,7 @@ class DevServerService {
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format /(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
]; ];
for (const pattern of urlPatterns) { for (const pattern of urlPatterns) {

View File

@@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
for (const line of lines) { for (const line of lines) {
// Check for numbered items or markdown headers // Check for numbered items or markdown headers
const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/); const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
if (titleMatch) { if (titleMatch) {
// Save previous suggestion // Save previous suggestion

View File

@@ -119,7 +119,15 @@ const eslintConfig = defineConfig([
}, },
rules: { rules: {
...ts.configs.recommended.rules, ...ts.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': [ '@typescript-eslint/ban-ts-comment': [
'error', 'error',

View File

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

View File

@@ -1,4 +1,3 @@
// @ts-nocheck - header component props with optional handlers and status variants
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'; import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
import { Feature } from '@/store/app-store'; import { Feature } from '@/store/app-store';
@@ -9,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 {
@@ -20,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';
@@ -27,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon'; import { getProviderIconForModel } from '@/components/ui/provider-icon';
function DuplicateMenuItems({
onDuplicate,
onDuplicateAsChild,
}: {
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
}) {
if (!onDuplicate) return null;
// When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper)
if (!onDuplicateAsChild) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
);
}
// When sub-child action is available, render a proper DropdownMenuSub with
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChild();
}}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
interface CardHeaderProps { interface CardHeaderProps {
feature: Feature; feature: Feature;
isDraggable: boolean; isDraggable: boolean;
@@ -36,6 +98,8 @@ interface CardHeaderProps {
onDelete: () => void; onDelete: () => void;
onViewOutput?: () => void; onViewOutput?: () => void;
onSpawnTask?: () => void; onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
dragHandleListeners?: DraggableSyntheticListeners; dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes; dragHandleAttributes?: DraggableAttributes;
} }
@@ -49,6 +113,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onDelete, onDelete,
onViewOutput, onViewOutput,
onSpawnTask, onSpawnTask,
onDuplicate,
onDuplicateAsChild,
dragHandleListeners, dragHandleListeners,
dragHandleAttributes, dragHandleAttributes,
}: CardHeaderProps) { }: CardHeaderProps) {
@@ -71,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<div className="absolute top-2 right-2 flex items-center gap-1"> <div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5"> <div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Spinner size="xs" /> <Spinner size="xs" />
{feature.startedAt && ( {typeof feature.startedAt === 'string' && (
<CountUpTimer <CountUpTimer
startedAt={feature.startedAt} startedAt={feature.startedAt}
className="text-[var(--status-in-progress)] text-[10px]" className="text-[var(--status-in-progress)] text-[10px]"
@@ -114,6 +180,10 @@ 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>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
{/* Model info in dropdown */} {/* Model info in dropdown */}
{(() => { {(() => {
const ProviderIcon = getProviderIconForModel(feature.model); const ProviderIcon = getProviderIconForModel(feature.model);
@@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
{/* Only render overflow menu when there are actionable items */}
{onDuplicate && (
<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">
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
)} )}
@@ -187,22 +280,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"
@@ -234,6 +311,41 @@ 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>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</> </>
)} )}
@@ -302,6 +414,10 @@ 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>
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
/>
{/* 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,
@@ -254,6 +258,8 @@ export const KanbanCard = memo(function KanbanCard({
onDelete={onDelete} onDelete={onDelete}
onViewOutput={onViewOutput} onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask} onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
dragHandleListeners={isDraggable ? listeners : undefined} dragHandleListeners={isDraggable ? listeners : undefined}
dragHandleAttributes={isDraggable ? attributes : undefined} dragHandleAttributes={isDraggable ? attributes : undefined}
/> />

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

@@ -517,7 +517,7 @@ export function useBoardActions({
} }
removeFeature(featureId); removeFeature(featureId);
persistFeatureDelete(featureId); await persistFeatureDelete(featureId);
}, },
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
); );
@@ -1090,6 +1090,38 @@ 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, stripping id, status (handled by create), and runtime/state fields
const {
id: _id,
status: _status,
startedAt: _startedAt,
error: _error,
summary: _summary,
spec: _spec,
passes: _passes,
planSpec: _planSpec,
descriptionHistory: _descriptionHistory,
titleGenerating: _titleGenerating,
...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,
@@ -1110,5 +1142,6 @@ export function useBoardActions({
handleForceStopFeature, handleForceStopFeature,
handleStartNextFeatures, handleStartNextFeatures,
handleArchiveAllVerified, handleArchiveAllVerified,
handleDuplicateFeature,
}; };
} }

View File

@@ -85,15 +85,48 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
throw new Error('Features API not available'); throw new Error('Features API not available');
} }
const result = await api.features.create(currentProject.path, feature as ApiFeature); // Capture previous cache snapshot for synchronous rollback on error
if (result.success && result.feature) { const previousFeatures = queryClient.getQueryData<Feature[]>(
updateFeature(result.feature.id, result.feature as Partial<Feature>); queryKeys.features.all(currentProject.path)
// Invalidate React Query cache to sync UI );
// Optimistically add to React Query cache for immediate board refresh
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(existing) => (existing ? [...existing, feature] : [feature])
);
try {
const result = await api.features.create(currentProject.path, feature as ApiFeature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature as Partial<Feature>);
// Update cache with server-confirmed feature before invalidating
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(features) => {
if (!features) return features;
return features.map((f) =>
f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f
);
}
);
} else if (!result.success) {
throw new Error(result.error || 'Failed to create feature on server');
}
// Always invalidate to sync with server state
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path), queryKey: queryKeys.features.all(currentProject.path),
}); });
} else if (!result.success) { } catch (error) {
throw new Error(result.error || 'Failed to create feature on server'); logger.error('Failed to persist feature creation:', error);
// Rollback optimistic update synchronously on error
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw error;
} }
}, },
[currentProject, updateFeature, queryClient] [currentProject, updateFeature, queryClient]
@@ -104,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
async (featureId: string) => { async (featureId: string) => {
if (!currentProject) return; if (!currentProject) return;
// Optimistically remove from React Query cache for immediate board refresh
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(currentProject.path)
);
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
);
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.features) { if (!api.features) {
logger.error('Features API not available'); // Rollback optimistic deletion since we can't persist
return; if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
} }
await api.features.delete(currentProject.path, featureId); await api.features.delete(currentProject.path, featureId);
// Invalidate React Query cache to sync UI // Invalidate to sync with server state
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path), queryKey: queryKeys.features.all(currentProject.path),
}); });
} catch (error) { } catch (error) {
logger.error('Failed to persist feature deletion:', error); logger.error('Failed to persist feature deletion:', error);
// Rollback optimistic update on error
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} }
}, },
[currentProject, queryClient] [currentProject, queryClient]

View File

@@ -46,6 +46,8 @@ 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) => void;
onDuplicateAsChild?: (feature: Feature) => void;
featuresWithContext: Set<string>; featuresWithContext: Set<string>;
runningAutoTasks: string[]; runningAutoTasks: string[];
onArchiveAllVerified: () => void; onArchiveAllVerified: () => void;
@@ -282,6 +284,8 @@ export function KanbanBoard({
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onSpawnTask, onSpawnTask,
onDuplicate,
onDuplicateAsChild,
featuresWithContext, featuresWithContext,
runningAutoTasks, runningAutoTasks,
onArchiveAllVerified, onArchiveAllVerified,
@@ -569,6 +573,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)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
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 +617,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)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
hasContext={featuresWithContext.has(feature.id)} hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}

View File

@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
} }
function internalToFeature(internal: FeatureWithId): Feature { function internalToFeature(internal: FeatureWithId): Feature {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, _locationIds, ...feature } = internal; const { _id, _locationIds, ...feature } = internal;
return feature; return feature;
} }

View File

@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
} }
function internalToPhase(internal: PhaseWithId): RoadmapPhase { function internalToPhase(internal: PhaseWithId): RoadmapPhase {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...phase } = internal; const { _id, ...phase } = internal;
return phase; return phase;
} }

View File

@@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') {
} }
// Mock API for development/fallback when no backend is available // Mock API for development/fallback when no backend is available
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _getMockElectronAPI = (): ElectronAPI => { const _getMockElectronAPI = (): ElectronAPI => {
return { return {
ping: async () => 'pong (mock)', ping: async () => 'pong (mock)',

View File

@@ -155,7 +155,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
const finishedAt = new Date().toISOString(); const finishedAt = new Date().toISOString();
// Remove from active sessions since it's no longer running // Remove from active sessions since it's no longer running
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
return { return {
@@ -202,7 +201,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
const session = state.sessions[sessionId]; const session = state.sessions[sessionId];
if (!session) return state; if (!session) return state;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [sessionId]: _, ...remainingSessions } = state.sessions; const { [sessionId]: _, ...remainingSessions } = state.sessions;
// Remove from active if this was the active session // Remove from active if this was the active session
@@ -231,7 +229,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
}); });
// Remove from active // Remove from active
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
return { return {

View File

@@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`;
export type FeatureStatusWithPipeline = export type FeatureStatusWithPipeline =
| 'backlog' | 'backlog'
| 'ready'
| 'in_progress' | 'in_progress'
| 'interrupted' | 'interrupted'
| 'waiting_approval' | 'waiting_approval'