mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +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.**
|
**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>
|
||||||
|
|
||||||
|
|||||||
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;
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user