mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
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:
@@ -14,10 +14,6 @@
|
||||
|
||||
**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>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
|
||||
|
||||
74
apps/server/eslint.config.mjs
Normal file
74
apps/server/eslint.config.mjs
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -101,7 +101,12 @@ export function createWorktreeRoutes(
|
||||
requireValidWorktree,
|
||||
createPullHandler()
|
||||
);
|
||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
||||
router.post(
|
||||
'/checkout-branch',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createCheckoutBranchHandler()
|
||||
);
|
||||
router.post(
|
||||
'/list-branches',
|
||||
validatePathParams('worktreePath'),
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*
|
||||
* 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 { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import path from 'path';
|
||||
import { stat } from 'fs/promises';
|
||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||
|
||||
export function createCheckoutBranchHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name (basic validation)
|
||||
const invalidChars = /[\s~^:?*\[\\]/;
|
||||
if (invalidChars.test(branchName)) {
|
||||
// Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Branch name contains invalid characters',
|
||||
error:
|
||||
'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch for reference
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Resolve and validate worktreePath to prevent traversal attacks.
|
||||
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
|
||||
// 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();
|
||||
|
||||
// Check if branch already exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||
// Branch exists
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() {
|
||||
// Branch doesn't exist, good to create
|
||||
}
|
||||
|
||||
// Create and checkout the new branch
|
||||
await execAsync(`git checkout -b ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Create and checkout the new branch (using argument array to avoid shell injection)
|
||||
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
|
||||
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await openInFileManager(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
// Both editor and file manager failed
|
||||
throw fallbackError;
|
||||
}
|
||||
const result = await openInFileManager(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.editorName}`,
|
||||
editorName: result.editorName,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Open in editor failed');
|
||||
|
||||
@@ -662,7 +662,7 @@ export class ClaudeUsageService {
|
||||
|
||||
resetTime = this.parseResetTime(resetText, type);
|
||||
// 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 };
|
||||
|
||||
@@ -124,7 +124,7 @@ class DevServerService {
|
||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||
/(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) {
|
||||
|
||||
@@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
|
||||
for (const line of lines) {
|
||||
// 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) {
|
||||
// Save previous suggestion
|
||||
|
||||
@@ -119,7 +119,15 @@ const eslintConfig = defineConfig([
|
||||
},
|
||||
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/ban-ts-comment': [
|
||||
'error',
|
||||
|
||||
@@ -590,6 +590,7 @@ export function BoardView() {
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
} = useBoardActions({
|
||||
currentProject,
|
||||
features: hookFeatures,
|
||||
@@ -1503,6 +1504,8 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
|
||||
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
pipelineConfig={pipelineConfig}
|
||||
@@ -1542,6 +1545,8 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
|
||||
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasksAllWorktrees}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck - header component props with optional handlers and status variants
|
||||
import { memo, useState } from 'react';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
@@ -9,6 +8,9 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -20,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';
|
||||
@@ -27,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
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 {
|
||||
feature: Feature;
|
||||
isDraggable: boolean;
|
||||
@@ -36,6 +98,8 @@ interface CardHeaderProps {
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAsChild?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
@@ -49,6 +113,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: 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="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" />
|
||||
{feature.startedAt && (
|
||||
{typeof feature.startedAt === 'string' && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
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" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
@@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -187,22 +280,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"
|
||||
@@ -234,6 +311,41 @@ 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>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -302,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<GitFork className="w-3 h-3 mr-2" />
|
||||
Spawn Sub-Task
|
||||
</DropdownMenuItem>
|
||||
<DuplicateMenuItems
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
/>
|
||||
{/* Model info in dropdown */}
|
||||
{(() => {
|
||||
const ProviderIcon = getProviderIconForModel(feature.model);
|
||||
|
||||
@@ -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,
|
||||
@@ -254,6 +258,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
onSpawnTask={onSpawnTask}
|
||||
onDuplicate={onDuplicate}
|
||||
onDuplicateAsChild={onDuplicateAsChild}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ export function useBoardActions({
|
||||
}
|
||||
|
||||
removeFeature(featureId);
|
||||
persistFeatureDelete(featureId);
|
||||
await persistFeatureDelete(featureId);
|
||||
},
|
||||
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]
|
||||
);
|
||||
@@ -1090,6 +1090,38 @@ export function useBoardActions({
|
||||
});
|
||||
}, [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 {
|
||||
handleAddFeature,
|
||||
handleUpdateFeature,
|
||||
@@ -1110,5 +1142,6 @@ export function useBoardActions({
|
||||
handleForceStopFeature,
|
||||
handleStartNextFeatures,
|
||||
handleArchiveAllVerified,
|
||||
handleDuplicateFeature,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,15 +85,48 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
throw new Error('Features API not available');
|
||||
}
|
||||
|
||||
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>);
|
||||
// Invalidate React Query cache to sync UI
|
||||
// Capture previous cache snapshot for synchronous rollback on error
|
||||
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path)
|
||||
);
|
||||
|
||||
// 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({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} else if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create feature on server');
|
||||
} catch (error) {
|
||||
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]
|
||||
@@ -104,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
async (featureId: string) => {
|
||||
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 {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
logger.error('Features API not available');
|
||||
return;
|
||||
// Rollback optimistic deletion since we can't persist
|
||||
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);
|
||||
// Invalidate React Query cache to sync UI
|
||||
// Invalidate to sync with server state
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} catch (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]
|
||||
|
||||
@@ -46,6 +46,8 @@ interface KanbanBoardProps {
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
onApprovePlan: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDuplicate?: (feature: Feature) => void;
|
||||
onDuplicateAsChild?: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
@@ -282,6 +284,8 @@ export function KanbanBoard({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onSpawnTask,
|
||||
onDuplicate,
|
||||
onDuplicateAsChild,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
@@ -569,6 +573,8 @@ export function KanbanBoard({
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
@@ -611,6 +617,8 @@ export function KanbanBoard({
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onDuplicate={() => onDuplicate?.(feature)}
|
||||
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
|
||||
@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
|
||||
}
|
||||
|
||||
function internalToFeature(internal: FeatureWithId): Feature {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _id, _locationIds, ...feature } = internal;
|
||||
return feature;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
|
||||
}
|
||||
|
||||
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _id, ...phase } = internal;
|
||||
return phase;
|
||||
}
|
||||
|
||||
@@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
// Mock API for development/fallback when no backend is available
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _getMockElectronAPI = (): ElectronAPI => {
|
||||
return {
|
||||
ping: async () => 'pong (mock)',
|
||||
|
||||
@@ -155,7 +155,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
const finishedAt = new Date().toISOString();
|
||||
|
||||
// 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;
|
||||
|
||||
return {
|
||||
@@ -202,7 +201,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
const session = state.sessions[sessionId];
|
||||
if (!session) return state;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [sessionId]: _, ...remainingSessions } = state.sessions;
|
||||
|
||||
// Remove from active if this was the active session
|
||||
@@ -231,7 +229,6 @@ export const useTestRunnersStore = create<TestRunnersState & TestRunnersActions>
|
||||
});
|
||||
|
||||
// Remove from active
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`;
|
||||
|
||||
export type FeatureStatusWithPipeline =
|
||||
| 'backlog'
|
||||
| 'ready'
|
||||
| 'in_progress'
|
||||
| 'interrupted'
|
||||
| 'waiting_approval'
|
||||
|
||||
Reference in New Issue
Block a user