diff --git a/README.md b/README.md index 98f8683e2..e13ad62e5 100644 --- a/README.md +++ b/README.md @@ -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. -

Table of Contents

diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs new file mode 100644 index 000000000..008c1f68e --- /dev/null +++ b/apps/server/eslint.config.mjs @@ -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; diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d0755..c607e72e4 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } - // Check for duplicate title if title is provided - if (feature.title && feature.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${feature.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1d..4d5e7a00e 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } - // Check for duplicate title if title is being updated - if (updates.title && updates.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle( - projectPath, - updates.title, - featureId // Exclude the current feature from duplicate check - ); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${updates.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - // Get the current feature to detect status changes const currentFeature = await featureLoader.get(projectPath, featureId); const previousStatus = currentFeature?.status as FeatureStatus | undefined; diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 992a7b48d..a7df37bb3 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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'), diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index ffa6e5e32..239634801 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -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 => { @@ -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, diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index c5ea6f9eb..f0d620d4c 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -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'); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 6438b5dc4..40cffd7f7 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -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 }; diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index d81e539c3..76cf31748 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -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) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index efa328022..0d43252fb 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -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 diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 3ad4d79d5..2400404fe 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -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', diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index a1e1fcecd..768b40a50 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -590,6 +590,7 @@ export function BoardView() { handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, } = useBoardActions({ currentProject, features: hookFeatures, @@ -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)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index cc97b202c..ac80d7ed3 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -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 ( + { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs" + > + + Duplicate + + ); + } + + // When sub-child action is available, render a proper DropdownMenuSub with + // DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions + return ( + + + + Duplicate + + + { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs" + > + + Duplicate + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + + ); +} + 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({
- {feature.startedAt && ( + {typeof feature.startedAt === 'string' && ( Spawn Sub-Task + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > + {/* Only render overflow menu when there are actionable items */} + {onDuplicate && ( + + + + + + + + + )}
)} @@ -187,22 +280,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + + +
)} @@ -302,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index eb44c49b5..ab109d5f1 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -52,6 +52,8 @@ interface KanbanCardProps { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, hasContext, isCurrentAutoTask, shortcutKey, @@ -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} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 0a08b1270..cac687eb4 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -42,6 +42,8 @@ export interface ListViewActionHandlers { onViewPlan?: (feature: Feature) => void; onApprovePlan?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; } export interface ListViewProps { @@ -313,6 +315,18 @@ export const ListView = memo(function ListView({ if (f) actionHandlers.onSpawnTask?.(f); } : undefined, + duplicate: actionHandlers.onDuplicate + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicate?.(f); + } + : undefined, + duplicateAsChild: actionHandlers.onDuplicateAsChild + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicateAsChild?.(f); + } + : undefined, }); }, [actionHandlers, allFeatures] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index bb5c53d16..60158d0fd 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -14,6 +14,7 @@ import { GitBranch, GitFork, ExternalLink, + Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { Feature } from '@/store/app-store'; @@ -43,6 +47,8 @@ export interface RowActionHandlers { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export interface RowActionsProps { @@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({ onClick={withClose(handlers.onSpawnTask)} /> )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} void; approvePlan?: (id: string) => void; spawnTask?: (id: string) => void; + duplicate?: (id: string) => void; + duplicateAsChild?: (id: string) => void; } ): RowActionHandlers { return { @@ -631,5 +764,9 @@ export function createRowActionHandlers( onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, + onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined, + onDuplicateAsChild: actions.duplicateAsChild + ? () => actions.duplicateAsChild!(featureId) + : undefined, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index aedeebaed..75d490309 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 48793f939..143e9c3ab 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -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); - // Invalidate React Query cache to sync UI + // Capture previous cache snapshot for synchronous rollback on error + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + + // Optimistically add to React Query cache for immediate board refresh + queryClient.setQueryData( + 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); + // Update cache with server-confirmed feature before invalidating + queryClient.setQueryData( + 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), + }); + } 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), }); - } else if (!result.success) { - throw new Error(result.error || 'Failed to create feature on server'); + 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( + queryKeys.features.all(currentProject.path) + ); + queryClient.setQueryData( + 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] diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 7f8573923..1a84080b4 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -46,6 +46,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; 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} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx index b27ec3e43..ad82a4d77 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -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; } diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx index c5d6ddd47..b13f35e78 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx @@ -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; } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822c..446b7b6fb 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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)', diff --git a/apps/ui/src/store/test-runners-store.ts b/apps/ui/src/store/test-runners-store.ts index b763c15a9..8f8f79843 100644 --- a/apps/ui/src/store/test-runners-store.ts +++ b/apps/ui/src/store/test-runners-store.ts @@ -155,7 +155,6 @@ export const useTestRunnersStore = create 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 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 }); // Remove from active - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; return { diff --git a/libs/types/src/pipeline.ts b/libs/types/src/pipeline.ts index 05a4b4aad..7190abbd8 100644 --- a/libs/types/src/pipeline.ts +++ b/libs/types/src/pipeline.ts @@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`; export type FeatureStatusWithPipeline = | 'backlog' + | 'ready' | 'in_progress' | 'interrupted' | 'waiting_approval'