From 525b2f82b613c401d1deaf0a8e625312675a6e6c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 15 Feb 2026 17:18:33 -0800 Subject: [PATCH 1/7] Update README to remove maintenance notice Removed maintenance warning from README. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 49b143436..5c5a67fe8 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

From e9802ac00c3f7b34e839f913640b97b6839d8e6e Mon Sep 17 00:00:00 2001 From: eclipxe Date: Wed, 21 Jan 2026 08:29:20 -0800 Subject: [PATCH 2/7] Feat: Add ability to duplicate a feature and duplicate as a child --- .../src/routes/features/routes/create.ts | 13 - .../src/routes/features/routes/update.ts | 17 -- apps/ui/src/components/views/board-view.tsx | 4 + .../components/kanban-card/card-header.tsx | 228 +++++++++++++++--- .../components/kanban-card/kanban-card.tsx | 6 + .../components/list-view/list-view.tsx | 14 ++ .../components/list-view/row-actions.tsx | 137 +++++++++++ .../board-view/hooks/use-board-actions.ts | 21 ++ .../views/board-view/kanban-board.tsx | 6 + 9 files changed, 386 insertions(+), 60 deletions(-) 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/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index d8be006dd..1266ea77c 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -590,6 +590,7 @@ export function BoardView() { handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, } = useBoardActions({ currentProject, features: hookFeatures, @@ -1465,6 +1466,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }, + onDuplicate: (feature) => handleDuplicateFeature(feature, false), + onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), }} runningAutoTasks={runningAutoTasks} pipelineConfig={pipelineConfig} @@ -1504,6 +1507,7 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} + onDuplicate={handleDuplicateFeature} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 793c31914..e3575c55a 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -8,6 +8,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -19,6 +22,7 @@ import { ChevronDown, ChevronUp, GitFork, + Copy, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { CountUpTimer } from '@/components/ui/count-up-timer'; @@ -35,6 +39,8 @@ interface CardHeaderProps { onDelete: () => void; onViewOutput?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export const CardHeaderSection = memo(function CardHeaderSection({ @@ -46,6 +52,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({ onDelete, onViewOutput, onSpawnTask, + onDuplicate, + onDuplicateAsChild, }: CardHeaderProps) { const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -109,6 +117,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -129,20 +170,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ {/* Backlog header */} {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
- + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-backlog-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a332f3059..4859331f7 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -52,6 +52,8 @@ interface KanbanCardProps { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, hasContext, isCurrentAutoTask, shortcutKey, @@ -249,6 +253,8 @@ export const KanbanCard = memo(function KanbanCard({ onDelete={onDelete} onViewOutput={onViewOutput} onSpawnTask={onSpawnTask} + onDuplicate={onDuplicate} + onDuplicateAsChild={onDuplicateAsChild} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 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 ebd805911..4f3c05179 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 @@ -1083,6 +1083,26 @@ export function useBoardActions({ }); }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); + const handleDuplicateFeature = useCallback( + async (feature: Feature, asChild: boolean = false) => { + // Copy all feature data, only override id/status (handled by create) and dependencies if as child + const { id: _id, status: _status, ...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, @@ -1103,5 +1123,6 @@ export function useBoardActions({ handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, }; } 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..ef0918721 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,7 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature, asChild: boolean) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -282,6 +283,7 @@ export function KanbanBoard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -569,6 +571,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -611,6 +615,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} From fa799d3cb53bb0b0ed0e64e4123764d4a85ac736 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 23:08:09 -0800 Subject: [PATCH 3/7] feat: Implement optimistic updates for feature persistence Add optimistic UI updates with rollback capability for feature creation and deletion operations. Await persistFeatureDelete promise and add Playwright testing dependency. --- .../board-view/hooks/use-board-actions.ts | 2 +- .../board-view/hooks/use-board-persistence.ts | 36 ++++++++++++++++--- package-lock.json | 13 ++----- 3 files changed, 34 insertions(+), 17 deletions(-) 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 4f3c05179..b0a917fee 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 @@ -510,7 +510,7 @@ export function useBoardActions({ } removeFeature(featureId); - persistFeatureDelete(featureId); + await persistFeatureDelete(featureId); }, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] ); 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 6e5d23f53..0d0ec3467 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 @@ -86,16 +86,26 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps return; } + // Optimistically add to React Query cache for immediate board refresh + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => (existing ? [...existing, feature] : [feature]) + ); + 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 - queryClient.invalidateQueries({ - queryKey: queryKeys.features.all(currentProject.path), - }); } + // 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 on error + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } }, [currentProject, updateFeature, queryClient] @@ -106,6 +116,15 @@ 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) { @@ -114,12 +133,19 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps } 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/package-lock.json b/package-lock.json index 8804b479c..3c60eba68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "automaker", "version": "0.13.0", "hasInstallScript": true, + "license": "MIT", "workspaces": [ "apps/*", "libs/*" @@ -56,6 +57,7 @@ "yaml": "2.7.0" }, "devDependencies": { + "@playwright/test": "1.57.0", "@types/cookie": "0.6.0", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -11475,7 +11477,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11497,7 +11498,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11519,7 +11519,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11541,7 +11540,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11563,7 +11561,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11585,7 +11582,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11607,7 +11603,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11624,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11651,7 +11645,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11666,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11687,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From b9653d633888137d5e4e91a5d73d1e698f239b9d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 16 Feb 2026 23:41:08 -0800 Subject: [PATCH 4/7] fix: Strip runtime and state fields when duplicating features --- .../views/board-view/hooks/use-board-actions.ts | 16 ++++++++++++++-- .../board-view/hooks/use-board-persistence.ts | 7 +++++++ 2 files changed, 21 insertions(+), 2 deletions(-) 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 89446fc5e..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 @@ -1092,8 +1092,20 @@ export function useBoardActions({ const handleDuplicateFeature = useCallback( async (feature: Feature, asChild: boolean = false) => { - // Copy all feature data, only override id/status (handled by create) and dependencies if as child - const { id: _id, status: _status, ...featureData } = feature; + // 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 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 d0da2d5ce..d3004f74b 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 @@ -132,6 +132,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps const api = getElectronAPI(); if (!api.features) { logger.error('Features API not available'); + // 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), + }); return; } From a09a2c76ae2c4bf14df564ccdf6363186d815640 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 00:13:38 -0800 Subject: [PATCH 5/7] fix: Address code review feedback and fix lint errors --- apps/server/eslint.config.mjs | 74 +++++++ .../routes/worktree/routes/checkout-branch.ts | 2 +- .../routes/worktree/routes/open-in-editor.ts | 21 +- .../src/services/claude-usage-service.ts | 2 +- .../server/src/services/dev-server-service.ts | 2 +- apps/server/src/services/ideation-service.ts | 2 +- apps/ui/eslint.config.mjs | 10 +- apps/ui/src/components/views/board-view.tsx | 3 +- .../components/kanban-card/card-header.tsx | 191 ++++++------------ .../board-view/hooks/use-board-persistence.ts | 23 ++- .../views/board-view/kanban-board.tsx | 12 +- .../components/edit-mode/features-section.tsx | 1 - .../components/edit-mode/roadmap-section.tsx | 1 - apps/ui/src/lib/electron.ts | 1 - apps/ui/src/store/test-runners-store.ts | 3 - 15 files changed, 183 insertions(+), 165 deletions(-) create mode 100644 apps/server/eslint.config.mjs 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/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index ffa6e5e32..7ffee2c08 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -37,7 +37,7 @@ export function createCheckoutBranchHandler() { } // Validate branch name (basic validation) - const invalidChars = /[\s~^:?*\[\\]/; + const invalidChars = /[\s~^:?*[\\]/; if (invalidChars.test(branchName)) { res.status(400).json({ success: false, 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 6f5f8e62e..768b40a50 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1545,7 +1545,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} - onDuplicate={handleDuplicateFeature} + 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 d7760e012..d69ebf8e9 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 @@ -31,6 +31,49 @@ 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; + return ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ ); +} + interface CardHeaderProps { feature: Feature; isDraggable: boolean; @@ -122,39 +165,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -217,39 +231,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} +
@@ -337,39 +322,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} + @@ -440,39 +396,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} -
- )} + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index d3004f74b..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,6 +85,11 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps throw new Error('Features API not available'); } + // 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), @@ -95,6 +100,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps 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'); } @@ -104,7 +119,10 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps }); } catch (error) { logger.error('Failed to persist feature creation:', error); - // Rollback optimistic update on 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), }); @@ -131,7 +149,6 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps try { const api = getElectronAPI(); if (!api.features) { - logger.error('Features API not available'); // Rollback optimistic deletion since we can't persist if (previousFeatures) { queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); @@ -139,7 +156,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); - return; + throw new Error('Features API not available'); } await api.features.delete(currentProject.path, featureId); 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 ef0918721..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,7 +46,8 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; - onDuplicate?: (feature: Feature, asChild: boolean) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -284,6 +285,7 @@ export function KanbanBoard({ onApprovePlan, onSpawnTask, onDuplicate, + onDuplicateAsChild, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -571,8 +573,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} - onDuplicate={() => onDuplicate?.(feature, false)} - onDuplicateAsChild={() => onDuplicate?.(feature, true)} + onDuplicate={() => onDuplicate?.(feature)} + onDuplicateAsChild={() => onDuplicateAsChild?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -615,8 +617,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} - onDuplicate={() => onDuplicate?.(feature, false)} - onDuplicateAsChild={() => onDuplicate?.(feature, true)} + 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 { From f7b3f75163ba8f65bb5c16257000d9dfb4f70fd1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 10:17:23 -0800 Subject: [PATCH 6/7] feat: Add path validation and security improvements to worktree routes --- apps/server/src/routes/worktree/index.ts | 7 +- .../routes/worktree/routes/checkout-branch.ts | 60 ++++++---- .../components/kanban-card/card-header.tsx | 103 +++++++++++------- 3 files changed, 106 insertions(+), 64 deletions(-) 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 7ffee2c08..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/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 d69ebf8e9..0f44ac21b 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'; @@ -39,37 +38,53 @@ function DuplicateMenuItems({ 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 flex-1 pr-0 rounded-r-none" + className="text-xs" > Duplicate - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - - { - e.stopPropagation(); - onDuplicateAsChild(); - }} - className="text-xs" - > - - Duplicate as Child - - - )} + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + +
); } @@ -122,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
- {feature.startedAt && ( + {typeof feature.startedAt === 'string' && ( - - - - - - - - + {/* Only render overflow menu when there are actionable items */} + {onDuplicate && ( + + + + + + + + + )}
)} From efcdd849b9dc6e2d45adeef6009a2db5b3eec5b6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 10:37:45 -0800 Subject: [PATCH 7/7] fix: Add 'ready' status to FeatureStatusWithPipeline type union --- .../views/board-view/components/kanban-card/card-header.tsx | 1 - libs/types/src/pipeline.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) 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 0f44ac21b..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 @@ -206,7 +206,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ !isSelectionMode && (feature.status === 'backlog' || feature.status === 'interrupted' || - // @ts-expect-error 'ready' is a valid runtime status used for backlog display but not in FeatureStatusWithPipeline union feature.status === 'ready') && (