diff --git a/apps/app/package.json b/apps/app/package.json index ad9100db3..528d66138 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/apps/app/src/components/ui/checkbox.tsx b/apps/app/src/components/ui/checkbox.tsx index 5b00e0cca..bc464c55f 100644 --- a/apps/app/src/components/ui/checkbox.tsx +++ b/apps/app/src/components/ui/checkbox.tsx @@ -13,9 +13,23 @@ interface CheckboxProps extends Omit & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + const Checkbox = React.forwardRef( - ({ className, onCheckedChange, ...props }, ref) => ( - ( + ( }} {...props} > - - - + + ) ); Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/apps/app/src/components/ui/dialog.tsx b/apps/app/src/components/ui/dialog.tsx index ea00207a2..ca028e215 100644 --- a/apps/app/src/components/ui/dialog.tsx +++ b/apps/app/src/components/ui/dialog.tsx @@ -6,6 +6,36 @@ import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + title?: string; + } & React.RefAttributes +>; + function Dialog({ ...props }: React.ComponentProps) { @@ -30,12 +60,20 @@ function DialogClose({ return ; } +const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + function DialogOverlay({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + className?: string; +}) { return ( - - {children} {showCloseButton && ( - Close - + )} - + ); } @@ -137,27 +175,42 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogTitle({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ); } function DialogDescription({ className, + children, + title, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; + title?: string; +}) { return ( - + > + {children} + ); } diff --git a/apps/app/src/components/ui/dropdown-menu.tsx b/apps/app/src/components/ui/dropdown-menu.tsx index cdefc5c74..456640934 100644 --- a/apps/app/src/components/ui/dropdown-menu.tsx +++ b/apps/app/src/components/ui/dropdown-menu.tsx @@ -6,9 +6,83 @@ import { Check, ChevronRight, Circle } from "lucide-react" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + } & React.RefAttributes +>; + +const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.HTMLAttributes & React.RefAttributes +>; + +const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.HTMLAttributes & React.RefAttributes +>; + +const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + } & React.RefAttributes +>; + +const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + const DropdownMenu = DropdownMenuPrimitive.Root -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +function DropdownMenuTrigger({ + children, + asChild, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) +} const DropdownMenuGroup = DropdownMenuPrimitive.Group @@ -16,15 +90,26 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +function DropdownMenuRadioGroup({ + children, + ...props +}: React.ComponentProps & { children?: React.ReactNode }) { + return ( + + {children} + + ) +} const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean + children?: React.ReactNode + className?: string } >(({ className, inset, children, ...props }, ref) => ( - {children} - + )) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName const DropdownMenuSubContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( , - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, sideOffset = 4, ...props }, ref) => ( , React.ComponentPropsWithoutRef & { inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - +>(({ className, inset, children, ...props }, ref) => ( + + > + {children} + )) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + children?: React.ReactNode; + } >(({ className, children, checked, ...props }, ref) => ( - - + - + {children} - + )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + children?: React.ReactNode + } & React.HTMLAttributes >(({ className, children, ...props }, ref) => ( - - + - + {children} - + )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName @@ -142,9 +239,11 @@ const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean + children?: React.ReactNode + className?: string } ->(({ className, inset, ...props }, ref) => ( - (({ className, inset, children, ...props }, ref) => ( + + > + {children} + )) DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName const DropdownMenuSeparator = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( - & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + function Popover({ ...props }: React.ComponentProps) { @@ -12,9 +26,18 @@ function Popover({ } function PopoverTrigger({ + children, + asChild, ...props -}: React.ComponentProps) { - return +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) } function PopoverContent({ @@ -22,10 +45,12 @@ function PopoverContent({ align = "center", sideOffset = 4, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + className?: string; +}) { return ( - , + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/app/src/components/ui/slider.tsx b/apps/app/src/components/ui/slider.tsx index 09253417f..9a15927b8 100644 --- a/apps/app/src/components/ui/slider.tsx +++ b/apps/app/src/components/ui/slider.tsx @@ -4,6 +4,33 @@ import * as React from "react"; import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@/lib/utils"; +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + +const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + interface SliderProps extends Omit, "defaultValue" | "dir"> { value?: number[]; defaultValue?: number[]; @@ -21,7 +48,7 @@ interface SliderProps extends Omit, "defau const Slider = React.forwardRef( ({ className, ...props }, ref) => ( - ( )} {...props} > - - - - - + + + + + ) ); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/apps/app/src/components/ui/tabs.tsx b/apps/app/src/components/ui/tabs.tsx index d849f0384..118986062 100644 --- a/apps/app/src/components/ui/tabs.tsx +++ b/apps/app/src/components/ui/tabs.tsx @@ -5,41 +5,86 @@ import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + function Tabs({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsList({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsTrigger({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsContent({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } diff --git a/apps/app/src/components/ui/task-progress-panel.tsx b/apps/app/src/components/ui/task-progress-panel.tsx new file mode 100644 index 000000000..1753f3090 --- /dev/null +++ b/apps/app/src/components/ui/task-progress-panel.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react"; +import { getElectronAPI } from "@/lib/electron"; +import type { AutoModeEvent } from "@/types/electron"; +import { Badge } from "@/components/ui/badge"; + +interface TaskInfo { + id: string; + description: string; + status: "pending" | "in_progress" | "completed"; + filePath?: string; + phase?: string; +} + +interface TaskProgressPanelProps { + featureId: string; + projectPath?: string; + className?: string; +} + +export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) { + const [tasks, setTasks] = useState([]); + const [isExpanded, setIsExpanded] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [currentTaskId, setCurrentTaskId] = useState(null); + + // Load initial tasks from feature's planSpec + const loadInitialTasks = useCallback(async () => { + if (!projectPath) { + setIsLoading(false); + return; + } + + try { + const api = getElectronAPI(); + if (!api?.features) { + setIsLoading(false); + return; + } + + const result = await api.features.get(projectPath, featureId); + if (result.success && result.feature?.planSpec?.tasks) { + const planTasks = result.feature.planSpec.tasks; + const currentId = result.feature.planSpec.currentTaskId; + const completedCount = result.feature.planSpec.tasksCompleted || 0; + + // Convert planSpec tasks to TaskInfo with proper status + const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({ + id: t.id, + description: t.description, + filePath: t.filePath, + phase: t.phase, + status: index < completedCount + ? "completed" as const + : t.id === currentId + ? "in_progress" as const + : "pending" as const, + })); + + setTasks(initialTasks); + setCurrentTaskId(currentId || null); + } + } catch (error) { + console.error("Failed to load initial tasks:", error); + } finally { + setIsLoading(false); + } + }, [featureId, projectPath]); + + // Load initial state on mount + useEffect(() => { + loadInitialTasks(); + }, [loadInitialTasks]); + + // Listen to task events for real-time updates + useEffect(() => { + const api = getElectronAPI(); + if (!api?.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + // Only handle events for this feature + if (!("featureId" in event) || event.featureId !== featureId) return; + + switch (event.type) { + case "auto_mode_task_started": + if ("taskId" in event && "taskDescription" in event) { + const taskEvent = event as Extract; + setCurrentTaskId(taskEvent.taskId); + + setTasks((prev) => { + // Check if task already exists + const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId); + + if (existingIndex !== -1) { + // Update status to in_progress and mark previous as completed + return prev.map((t, idx) => { + if (t.id === taskEvent.taskId) { + return { ...t, status: "in_progress" as const }; + } + // If we are moving to a task that is further down the list, assume previous ones are completed + // This is a heuristic, but usually correct for sequential execution + if (idx < existingIndex && t.status !== "completed") { + return { ...t, status: "completed" as const }; + } + return t; + }); + } + + // Add new task if it doesn't exist (fallback) + return [ + ...prev, + { + id: taskEvent.taskId, + description: taskEvent.taskDescription, + status: "in_progress" as const, + }, + ]; + }); + } + break; + + case "auto_mode_task_complete": + if ("taskId" in event) { + const taskEvent = event as Extract; + setTasks((prev) => + prev.map((t) => + t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t + ) + ); + setCurrentTaskId(null); + } + break; + } + }); + + return unsubscribe; + }, [featureId]); + + const completedCount = tasks.filter((t) => t.status === "completed").length; + const totalCount = tasks.length; + const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + + if (isLoading || tasks.length === 0) { + return null; + } + + return ( +
+ + +
+
+
+ {/* Vertical Connector Line */} +
+ +
+ {tasks.map((task, index) => { + const isActive = task.status === "in_progress"; + const isCompleted = task.status === "completed"; + const isPending = task.status === "pending"; + + return ( +
+ {/* Icon Status */} +
+ {isCompleted && } + {isActive && } + {isPending && } +
+ + {/* Task Content */} +
+
+
+

+ {task.description} +

+ {isActive && ( + + Active + + )} +
+ + {(task.filePath || isActive) && ( +
+ {task.filePath ? ( + <> + + + {task.filePath} + + + ) : ( + /* Spacer */ + )} +
+ )} +
+
+
+ ); + })} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/components/ui/tooltip.tsx b/apps/app/src/components/ui/tooltip.tsx index bc4432061..30d3e44b0 100644 --- a/apps/app/src/components/ui/tooltip.tsx +++ b/apps/app/src/components/ui/tooltip.tsx @@ -5,18 +5,47 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + const TooltipProvider = TooltipPrimitive.Provider const Tooltip = TooltipPrimitive.Root -const TooltipTrigger = TooltipPrimitive.Trigger +function TooltipTrigger({ + children, + asChild, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) +} const TooltipContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, sideOffset = 6, ...props }, ref) => ( - (null); + // State for viewing plan in read-only mode + const [viewPlanFeature, setViewPlanFeature] = useState(null); // Worktree dialog states const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = @@ -145,6 +151,8 @@ export function BoardView() { } = useSuggestionsState(); // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(""); + // Plan approval loading state + const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; const creatingSpecProjectPath = specCreatingForProject ?? undefined; @@ -389,6 +397,130 @@ export function BoardView() { currentProject, }); + // Find feature for pending plan approval + const pendingApprovalFeature = useMemo(() => { + if (!pendingPlanApproval) return null; + return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; + }, [pendingPlanApproval, hookFeatures]); + + // Handle plan approval + const handlePlanApprove = useCallback( + async (editedPlan?: string) => { + if (!pendingPlanApproval || !currentProject) return; + + const featureId = pendingPlanApproval.featureId; + setIsPlanApprovalLoading(true); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.approvePlan) { + throw new Error("Plan approval API not available"); + } + + const result = await api.autoMode.approvePlan( + pendingPlanApproval.projectPath, + pendingPlanApproval.featureId, + true, + editedPlan + ); + + if (result.success) { + // Immediately update local feature state to hide "Approve Plan" button + // Get current feature to preserve version + const currentFeature = hookFeatures.find(f => f.id === featureId); + updateFeature(featureId, { + planSpec: { + status: 'approved', + content: editedPlan || pendingPlanApproval.planContent, + version: currentFeature?.planSpec?.version || 1, + approvedAt: new Date().toISOString(), + reviewedByUser: true, + }, + }); + // Reload features from server to ensure sync + loadFeatures(); + } else { + console.error("[Board] Failed to approve plan:", result.error); + } + } catch (error) { + console.error("[Board] Error approving plan:", error); + } finally { + setIsPlanApprovalLoading(false); + setPendingPlanApproval(null); + } + }, + [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + ); + + // Handle plan rejection + const handlePlanReject = useCallback( + async (feedback?: string) => { + if (!pendingPlanApproval || !currentProject) return; + + const featureId = pendingPlanApproval.featureId; + setIsPlanApprovalLoading(true); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.approvePlan) { + throw new Error("Plan approval API not available"); + } + + const result = await api.autoMode.approvePlan( + pendingPlanApproval.projectPath, + pendingPlanApproval.featureId, + false, + undefined, + feedback + ); + + if (result.success) { + // Immediately update local feature state + // Get current feature to preserve version + const currentFeature = hookFeatures.find(f => f.id === featureId); + updateFeature(featureId, { + status: 'backlog', + planSpec: { + status: 'rejected', + content: pendingPlanApproval.planContent, + version: currentFeature?.planSpec?.version || 1, + reviewedByUser: true, + }, + }); + // Reload features from server to ensure sync + loadFeatures(); + } else { + console.error("[Board] Failed to reject plan:", result.error); + } + } catch (error) { + console.error("[Board] Error rejecting plan:", error); + } finally { + setIsPlanApprovalLoading(false); + setPendingPlanApproval(null); + } + }, + [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + ); + + // Handle opening approval dialog from feature card button + const handleOpenApprovalDialog = useCallback( + (feature: Feature) => { + if (!feature.planSpec?.content || !currentProject) return; + + // Determine the planning mode for approval (skip should never have a plan requiring approval) + const mode = feature.planningMode; + const approvalMode: "lite" | "spec" | "full" = + mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec'; + + // Re-open the approval dialog with the feature's plan data + setPendingPlanApproval({ + featureId: feature.id, + projectPath: currentProject.path, + planContent: feature.planSpec.content, + planningMode: approvalMode, + }); + }, + [currentProject, setPendingPlanApproval] + ); + if (!currentProject) { return (
setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} shortcuts={shortcuts} @@ -617,6 +751,34 @@ export function BoardView() { setIsGenerating={setIsGeneratingSuggestions} /> + {/* Plan Approval Dialog */} + { + if (!open) { + setPendingPlanApproval(null); + } + }} + feature={pendingApprovalFeature} + planContent={pendingPlanApproval?.planContent || ""} + onApprove={handlePlanApprove} + onReject={handlePlanReject} + isLoading={isPlanApprovalLoading} + /> + + {/* View Plan Dialog (read-only) */} + {viewPlanFeature && viewPlanFeature.planSpec?.content && ( + !open && setViewPlanFeature(null)} + feature={viewPlanFeature} + planContent={viewPlanFeature.planSpec.content} + onApprove={() => setViewPlanFeature(null)} + onReject={() => setViewPlanFeature(null)} + viewOnly={true} + /> + )} + {/* Create Worktree Dialog */} void; onImplement?: () => void; onComplete?: () => void; + onViewPlan?: () => void; + onApprovePlan?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -130,6 +132,8 @@ export const KanbanCard = memo(function KanbanCard({ onCommit, onImplement, onComplete, + onViewPlan, + onApprovePlan, hasContext, isCurrentAutoTask, shortcutKey, @@ -876,9 +880,26 @@ export const KanbanCard = memo(function KanbanCard({ )} {/* Actions */} -
+
{isCurrentAutoTask && ( <> + {/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} {onViewOutput && ( + )} {feature.skipTests && onManualVerify ? ( + {feature.planSpec?.content && onViewPlan && ( + + )} {onImplement && ( +
+ )} + + {/* Plan Content */} +
+ {isEditMode && !viewOnly ? ( +