Merge pull request #160 from AutoMaker-Org/implement-planning/speckits-rebase

Implement planning/speckits rebase
This commit is contained in:
Web Dev Cody
2025-12-17 22:43:14 -05:00
committed by GitHub
39 changed files with 4905 additions and 713 deletions

View File

@@ -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",

View File

@@ -13,9 +13,23 @@ interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElemen
required?: boolean;
}
const CheckboxRoot = CheckboxPrimitive.Root as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLButtonElement>
>;
const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Indicator> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLSpanElement>
>;
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => (
<CheckboxPrimitive.Root
({ className, onCheckedChange, children: _children, ...props }, ref) => (
<CheckboxRoot
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
@@ -29,12 +43,12 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
}}
{...props}
>
<CheckboxPrimitive.Indicator
<CheckboxIndicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</CheckboxIndicator>
</CheckboxRoot>
)
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;

View File

@@ -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<typeof DialogPrimitive.Content> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLButtonElement>
>;
const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLHeadingElement>
>;
const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
children?: React.ReactNode;
className?: string;
title?: string;
} & React.RefAttributes<HTMLParagraphElement>
>;
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
@@ -30,12 +60,20 @@ function DialogClose({
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
className?: string;
}) {
return (
<DialogPrimitive.Overlay
<DialogOverlayPrimitive
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
@@ -66,7 +104,7 @@ function DialogContent({
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
<DialogContentPrimitive
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
@@ -91,7 +129,7 @@ function DialogContent({
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
<DialogClosePrimitive
data-slot="dialog-close"
className={cn(
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
@@ -105,9 +143,9 @@ function DialogContent({
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogClosePrimitive>
)}
</DialogPrimitive.Content>
</DialogContentPrimitive>
</DialogPortal>
);
}
@@ -137,27 +175,42 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
function DialogTitle({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
children?: React.ReactNode;
className?: string;
}) {
return (
<DialogPrimitive.Title
<DialogTitlePrimitive
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
{...props}
/>
>
{children}
</DialogTitlePrimitive>
);
}
function DialogDescription({
className,
children,
title,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
}: React.ComponentProps<typeof DialogPrimitive.Description> & {
children?: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<DialogPrimitive.Description
<DialogDescriptionPrimitive
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
title={title}
{...props}
/>
>
{children}
</DialogDescriptionPrimitive>
);
}

View File

@@ -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<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLSpanElement>
>;
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
function DropdownMenuTrigger({
children,
asChild,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
}) {
return (
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
{children}
</DropdownMenuTriggerPrimitive>
)
}
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<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
return (
<DropdownMenuRadioGroupPrimitive {...props}>
{children}
</DropdownMenuRadioGroupPrimitive>
)
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
children?: React.ReactNode
className?: string
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
<DropdownMenuSubTriggerPrimitive
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
@@ -35,13 +120,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
</DropdownMenuSubTriggerPrimitive>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
@@ -58,7 +145,9 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
className?: string;
}
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
@@ -78,9 +167,10 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
children?: React.ReactNode
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
@@ -88,15 +178,20 @@ const DropdownMenuItem = React.forwardRef<
className
)}
{...props}
/>
>
{children}
</DropdownMenuItemPrimitive>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
className?: string;
children?: React.ReactNode;
}
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
<DropdownMenuCheckboxItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
@@ -106,21 +201,23 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DropdownMenuItemIndicatorPrimitive>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuItemIndicatorPrimitive>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
</DropdownMenuCheckboxItemPrimitive>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
<DropdownMenuRadioItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
@@ -129,12 +226,12 @@ const DropdownMenuRadioItem = React.forwardRef<
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DropdownMenuItemIndicatorPrimitive>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuItemIndicatorPrimitive>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
</DropdownMenuRadioItemPrimitive>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
@@ -142,9 +239,11 @@ const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
children?: React.ReactNode
className?: string
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuLabelPrimitive
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
@@ -152,15 +251,19 @@ const DropdownMenuLabel = React.forwardRef<
className
)}
{...props}
/>
>
{children}
</DropdownMenuLabelPrimitive>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
<DropdownMenuSeparatorPrimitive
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}

View File

@@ -5,6 +5,20 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
@@ -12,9 +26,18 @@ function Popover({
}
function PopoverTrigger({
children,
asChild,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
}) {
return (
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
{children}
</PopoverTriggerPrimitive>
)
}
function PopoverContent({
@@ -22,10 +45,12 @@ function PopoverContent({
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
className?: string;
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
<PopoverContentPrimitive
data-slot="popover-content"
align={align}
sideOffset={sideOffset}

View File

@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -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<typeof SliderPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLSpanElement>
>;
const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLSpanElement>
>;
const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Range> & {
className?: string;
} & React.RefAttributes<HTMLSpanElement>
>;
const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Thumb> & {
className?: string;
} & React.RefAttributes<HTMLSpanElement>
>;
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
value?: number[];
defaultValue?: number[];
@@ -21,7 +48,7 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defau
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
<SliderRootPrimitive
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
@@ -29,11 +56,11 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderRootPrimitive>
)
);
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -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<typeof TabsPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLButtonElement>
>;
const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
function Tabs({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
}: React.ComponentProps<typeof TabsPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
}) {
return (
<TabsPrimitive.Root
<TabsRootPrimitive
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
>
{children}
</TabsRootPrimitive>
)
}
function TabsList({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
}: React.ComponentProps<typeof TabsPrimitive.List> & {
children?: React.ReactNode;
className?: string;
}) {
return (
<TabsPrimitive.List
<TabsListPrimitive
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
className
)}
{...props}
/>
>
{children}
</TabsListPrimitive>
)
}
function TabsTrigger({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
children?: React.ReactNode;
className?: string;
}) {
return (
<TabsPrimitive.Trigger
<TabsTriggerPrimitive
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
@@ -51,20 +96,28 @@ function TabsTrigger({
className
)}
{...props}
/>
>
{children}
</TabsTriggerPrimitive>
)
}
function TabsContent({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
}: React.ComponentProps<typeof TabsPrimitive.Content> & {
children?: React.ReactNode;
className?: string;
}) {
return (
<TabsPrimitive.Content
<TabsContentPrimitive
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
>
{children}
</TabsContentPrimitive>
)
}

View File

@@ -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<TaskInfo[]>([]);
const [isExpanded, setIsExpanded] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(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<AutoModeEvent, { type: "auto_mode_task_started" }>;
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<AutoModeEvent, { type: "auto_mode_task_complete" }>;
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 (
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
)}>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-foreground/70" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-col items-start gap-0.5">
<h3 className="font-semibold text-sm tracking-tight">Execution Plan</h3>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-medium">
{completedCount} of {totalCount} tasks completed
</span>
</div>
</div>
<div className="flex items-center gap-3">
{/* Circular Progress (Mini) */}
<div className="relative h-8 w-8 flex items-center justify-center">
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
<circle
className="text-primary transition-all duration-500 ease-in-out"
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
strokeDasharray={63}
strokeDashoffset={63 - (63 * progressPercent) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
</div>
</div>
</button>
<div className={cn(
"grid transition-all duration-300 ease-in-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}>
<div className="overflow-hidden">
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
{/* Vertical Connector Line */}
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
<div className="space-y-5">
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const isCompleted = task.status === "completed";
const isPending = task.status === "pending";
return (
<div
key={task.id}
className={cn(
"relative flex gap-4 group/item transition-all duration-300",
isPending && "opacity-60 hover:opacity-100"
)}
>
{/* Icon Status */}
<div className={cn(
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
isPending && "bg-muted border-border text-muted-foreground"
)}>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>
{/* Task Content */}
<div className={cn(
"flex-1 pt-1 min-w-0 transition-all",
isActive && "translate-x-1"
)}>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-4">
<p className={cn(
"text-sm font-medium leading-none truncate pr-4",
isCompleted && "text-muted-foreground line-through decoration-border/60",
isActive && "text-primary font-semibold"
)}>
{task.description}
</p>
{isActive && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
Active
</Badge>
)}
</div>
{(task.filePath || isActive) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
{task.filePath ? (
<>
<FileCode className="h-3 w-3 opacity-70" />
<span className="truncate opacity-80 hover:opacity-100 transition-opacity">
{task.filePath}
</span>
</>
) : (
<span className="h-3 block" /> /* Spacer */
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<typeof TooltipPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipTrigger({
children,
asChild,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
}) {
return (
<TooltipTriggerPrimitive asChild={asChild} {...props}>
{children}
</TooltipTriggerPrimitive>
)
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
className?: string;
}
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
<TooltipContentPrimitive
ref={ref}
sideOffset={sideOffset}
className={cn(

View File

@@ -30,6 +30,7 @@ import {
EditFeatureDialog,
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
@@ -68,6 +69,9 @@ export function BoardView() {
setKanbanCardDetailLevel,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
setPendingPlanApproval,
updateFeature,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
@@ -96,6 +100,8 @@ export function BoardView() {
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(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 (
<div
@@ -506,6 +638,8 @@ export function BoardView() {
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
@@ -617,6 +751,34 @@ export function BoardView() {
setIsGenerating={setIsGeneratingSuggestions}
/>
{/* Plan Approval Dialog */}
<PlanApprovalDialog
open={pendingPlanApproval !== null}
onOpenChange={(open) => {
if (!open) {
setPendingPlanApproval(null);
}
}}
feature={pendingApprovalFeature}
planContent={pendingPlanApproval?.planContent || ""}
onApprove={handlePlanApprove}
onReject={handlePlanReject}
isLoading={isPlanApprovalLoading}
/>
{/* View Plan Dialog (read-only) */}
{viewPlanFeature && viewPlanFeature.planSpec?.content && (
<PlanApprovalDialog
open={true}
onOpenChange={(open) => !open && setViewPlanFeature(null)}
feature={viewPlanFeature}
planContent={viewPlanFeature.planSpec.content}
onApprove={() => setViewPlanFeature(null)}
onReject={() => setViewPlanFeature(null)}
viewOnly={true}
/>
)}
{/* Create Worktree Dialog */}
<CreateWorktreeDialog
open={showCreateWorktreeDialog}

View File

@@ -105,6 +105,8 @@ interface KanbanCardProps {
onCommit?: () => 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 */}
<div className="flex gap-1.5">
<div className="flex flex-wrap gap-1.5">
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-running-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Approve Plan</span>
</Button>
)}
{onViewOutput && (
<Button
variant="secondary"
@@ -891,8 +912,8 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Logs
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
@@ -907,7 +928,7 @@ export const KanbanCard = memo(function KanbanCard({
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
@@ -922,6 +943,23 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Approve Plan
</Button>
)}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
@@ -1076,6 +1114,22 @@ export const KanbanCard = memo(function KanbanCard({
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{feature.planSpec?.content && onViewPlan && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onViewPlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-plan-${feature.id}`}
title="View Plan"
>
<Eye className="w-3 h-3" />
</Button>
)}
{onImplement && (
<Button
variant="default"

View File

@@ -23,6 +23,7 @@ import {
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
@@ -36,6 +37,7 @@ import {
ThinkingLevel,
FeatureImage,
AIProfile,
PlanningMode,
} from "@/store/app-store";
import {
ModelSelector,
@@ -43,6 +45,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
@@ -65,6 +68,8 @@ interface AddFeatureDialogProps {
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -107,9 +112,11 @@ export function AddFeatureDialog({
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
// Get enhancement model, planning mode defaults, and worktrees setting from store
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Sync defaults when dialog opens
useEffect(() => {
@@ -119,8 +126,10 @@ export function AddFeatureDialog({
skipTests: defaultSkipTests,
branchName: defaultBranch,
}));
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
}, [open, defaultSkipTests, defaultBranch]);
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
const handleAdd = () => {
if (!newFeature.description.trim()) {
@@ -145,6 +154,8 @@ export function AddFeatureDialog({
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
});
// Reset form
@@ -160,6 +171,8 @@ export function AddFeatureDialog({
thinkingLevel: "none",
branchName: defaultBranch,
});
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
@@ -230,13 +243,13 @@ export function AddFeatureDialog({
<DialogContent
compact={!isMaximized}
data-testid="add-feature-dialog"
onPointerDownOutside={(e) => {
onPointerDownOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
onInteractOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
@@ -262,9 +275,9 @@ export function AddFeatureDialog({
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="testing" data-testid="tab-testing">
<FlaskConical className="w-4 h-4 mr-2" />
Testing
<TabsTrigger value="options" data-testid="tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
@@ -453,11 +466,22 @@ export function AddFeatureDialog({
)}
</TabsContent>
{/* Testing Tab */}
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={newFeature.description}
testIdPrefix="add-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>

View File

@@ -12,6 +12,7 @@ import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
@@ -169,6 +170,64 @@ export function AgentOutputModal({
newContent = prepContent;
break;
case "planning_started":
// Show when planning mode begins
if ("mode" in event && "message" in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
// Show when plan requires approval
if ("planningMode" in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
// Show when plan is manually approved
if ("hasEdits" in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case "plan_auto_approved":
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
case "auto_mode_task_started":
// Show when a task starts
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
case "auto_mode_task_complete":
// Show task completion progress
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
case "auto_mode_phase_complete":
// Show phase completion for full mode
if ("phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
@@ -288,6 +347,13 @@ export function AgentOutputModal({
</DialogDescription>
</DialogHeader>
{/* Task Progress Panel - shows when tasks are being executed */}
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (

View File

@@ -23,6 +23,7 @@ import {
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
@@ -37,6 +38,7 @@ import {
ThinkingLevel,
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
import {
ModelSelector,
@@ -44,6 +46,7 @@ import {
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
@@ -68,6 +71,8 @@ interface EditFeatureDialogProps {
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
) => void;
categorySuggestions: string[];
@@ -98,13 +103,18 @@ export function EditFeatureDialog({
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
useEffect(() => {
setEditingFeature(feature);
if (!feature) {
if (feature) {
setPlanningMode(feature.planningMode ?? 'skip');
setRequirePlanApproval(feature.requirePlanApproval ?? false);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
}
@@ -130,6 +140,8 @@ export function EditFeatureDialog({
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
};
onUpdate(editingFeature.id, updates);
@@ -207,13 +219,13 @@ export function EditFeatureDialog({
<DialogContent
compact={!isMaximized}
data-testid="edit-feature-dialog"
onPointerDownOutside={(e) => {
onPointerDownOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
onInteractOutside={(e: CustomEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
@@ -237,9 +249,9 @@ export function EditFeatureDialog({
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="testing" data-testid="edit-tab-testing">
<FlaskConical className="w-4 h-4 mr-2" />
Testing
<TabsTrigger value="options" data-testid="edit-tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
@@ -450,11 +462,22 @@ export function EditFeatureDialog({
)}
</TabsContent>
{/* Testing Tab */}
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={editingFeature.description}
testIdPrefix="edit-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>

View File

@@ -58,7 +58,7 @@ export function FollowUpDialog({
<DialogContent
compact={!isMaximized}
data-testid="follow-up-dialog"
onKeyDown={(e) => {
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) {
e.preventDefault();
onSend();

View File

@@ -6,3 +6,4 @@ export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog"
export { EditFeatureDialog } from "./edit-feature-dialog";
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
export { FollowUpDialog } from "./follow-up-dialog";
export { PlanApprovalDialog } from "./plan-approval-dialog";

View File

@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Markdown } from "@/components/ui/markdown";
import { Label } from "@/components/ui/label";
import { Feature } from "@/store/app-store";
import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react";
interface PlanApprovalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
feature: Feature | null;
planContent: string;
onApprove: (editedPlan?: string) => void;
onReject: (feedback?: string) => void;
isLoading?: boolean;
viewOnly?: boolean;
}
export function PlanApprovalDialog({
open,
onOpenChange,
feature,
planContent,
onApprove,
onReject,
isLoading = false,
viewOnly = false,
}: PlanApprovalDialogProps) {
const [isEditMode, setIsEditMode] = useState(false);
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState("");
// Reset state when dialog opens or plan content changes
useEffect(() => {
if (open) {
setEditedPlan(planContent);
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback("");
}
}, [open, planContent]);
const handleApprove = () => {
// Only pass edited plan if it was modified
const wasEdited = editedPlan !== planContent;
onApprove(wasEdited ? editedPlan : undefined);
};
const handleReject = () => {
if (showRejectFeedback) {
onReject(rejectFeedback.trim() || undefined);
} else {
setShowRejectFeedback(true);
}
};
const handleCancelReject = () => {
setShowRejectFeedback(false);
setRejectFeedback("");
};
const handleClose = (open: boolean) => {
if (!open && !isLoading) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="max-w-4xl"
data-testid="plan-approval-dialog"
>
<DialogHeader>
<DialogTitle>{viewOnly ? "View Plan" : "Review Plan"}</DialogTitle>
<DialogDescription>
{viewOnly
? "View the generated plan for this feature."
: "Review the generated plan before implementation begins."}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? "..." : ""}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{/* Mode Toggle - Only show when not in viewOnly mode */}
{!viewOnly && (
<div className="flex items-center justify-between mb-3">
<Label className="text-sm text-muted-foreground">
{isEditMode ? "Edit Mode" : "View Mode"}
</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditMode(!isEditMode)}
disabled={isLoading}
>
{isEditMode ? (
<>
<Eye className="w-4 h-4 mr-2" />
View
</>
) : (
<>
<Edit2 className="w-4 h-4 mr-2" />
Edit
</>
)}
</Button>
</div>
)}
{/* Plan Content */}
<div className="flex-1 overflow-y-auto max-h-[70vh] border border-border rounded-lg">
{isEditMode && !viewOnly ? (
<Textarea
value={editedPlan}
onChange={(e) => setEditedPlan(e.target.value)}
className="min-h-[400px] h-full w-full border-0 rounded-lg resize-none font-mono text-sm"
placeholder="Enter plan content..."
disabled={isLoading}
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || "No plan content available."}</Markdown>
</div>
)}
</div>
{/* Revision Feedback Section - Only show when not in viewOnly mode */}
{showRejectFeedback && !viewOnly && (
<div className="mt-4 space-y-2">
<Label htmlFor="reject-feedback">What changes would you like?</Label>
<Textarea
id="reject-feedback"
value={rejectFeedback}
onChange={(e) => setRejectFeedback(e.target.value)}
placeholder="Describe the changes you'd like to see in the plan..."
className="min-h-[80px]"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Leave empty to cancel the feature, or provide feedback to regenerate the plan.
</p>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0 gap-2">
{viewOnly ? (
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
) : showRejectFeedback ? (
<>
<Button
variant="ghost"
onClick={handleCancelReject}
disabled={isLoading}
>
Back
</Button>
<Button
variant="secondary"
onClick={handleReject}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{rejectFeedback.trim() ? "Revise Plan" : "Cancel Feature"}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={handleReject}
disabled={isLoading}
>
<RefreshCw className="w-4 h-4 mr-2" />
Request Changes
</Button>
<Button
onClick={handleApprove}
disabled={isLoading}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Check className="w-4 h-4 mr-2" />
)}
Approve
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import {
FeatureImage,
AgentModel,
ThinkingLevel,
PlanningMode,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
@@ -150,6 +151,8 @@ export function useBoardActions({
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => {
let worktreePath: string | undefined;
@@ -194,6 +197,8 @@ export function useBoardActions({
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
) => {
// Get the current feature to check if branch is changing

View File

@@ -210,6 +210,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
.play()
.catch((err) => console.warn("Could not play ding sound:", err));
}
} else if (event.type === "plan_approval_required") {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
console.log("[Board] Plan approval required, reloading features...");
loadFeatures();
} else if (event.type === "auto_mode_error") {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(

View File

@@ -47,6 +47,8 @@ interface KanbanBoardProps {
onCommit: (feature: Feature) => void;
onComplete: (feature: Feature) => void;
onImplement: (feature: Feature) => void;
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
@@ -77,6 +79,8 @@ export function KanbanBoard({
onCommit,
onComplete,
onImplement,
onViewPlan,
onApprovePlan,
featuresWithContext,
runningAutoTasks,
shortcuts,
@@ -189,6 +193,8 @@ export function KanbanBoard({
onCommit={() => onCommit(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id

View File

@@ -4,3 +4,4 @@ export * from "./thinking-level-selector";
export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./planning-mode-selector";

View File

@@ -0,0 +1,343 @@
"use client";
import { useState } from "react";
import {
Zap, ClipboardList, FileText, ScrollText,
Loader2, Check, Eye, RefreshCw, Sparkles
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { PlanSpec } from "@/store/app-store";
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// Re-export for backwards compatibility
export type { ParsedTask, PlanSpec } from "@/store/app-store";
interface PlanningModeSelectorProps {
mode: PlanningMode;
onModeChange: (mode: PlanningMode) => void;
requireApproval?: boolean;
onRequireApprovalChange?: (require: boolean) => void;
planSpec?: PlanSpec;
onGenerateSpec?: () => void;
onApproveSpec?: () => void;
onRejectSpec?: () => void;
onViewSpec?: () => void;
isGenerating?: boolean;
featureDescription?: string; // For auto-generation context
testIdPrefix?: string;
compact?: boolean; // For use in dialogs vs settings
}
const modes = [
{
value: 'skip' as const,
label: 'Skip',
description: 'Direct implementation, no upfront planning',
icon: Zap,
color: 'text-emerald-500',
bgColor: 'bg-emerald-500/10',
borderColor: 'border-emerald-500/30',
badge: 'Default',
},
{
value: 'lite' as const,
label: 'Lite',
description: 'Think through approach, create task list',
icon: ClipboardList,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
borderColor: 'border-blue-500/30',
},
{
value: 'spec' as const,
label: 'Spec',
description: 'Generate spec with acceptance criteria',
icon: FileText,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
borderColor: 'border-purple-500/30',
badge: 'Approval Required',
},
{
value: 'full' as const,
label: 'Full',
description: 'Comprehensive spec with phased plan',
icon: ScrollText,
color: 'text-amber-500',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
badge: 'Approval Required',
},
];
export function PlanningModeSelector({
mode,
onModeChange,
requireApproval,
onRequireApprovalChange,
planSpec,
onGenerateSpec,
onApproveSpec,
onRejectSpec,
onViewSpec,
isGenerating = false,
featureDescription,
testIdPrefix = 'planning',
compact = false,
}: PlanningModeSelectorProps) {
const [showPreview, setShowPreview] = useState(false);
const selectedMode = modes.find(m => m.value === mode);
const requiresApproval = mode === 'spec' || mode === 'full';
const canGenerate = requiresApproval && featureDescription?.trim() && !isGenerating;
const hasSpec = planSpec && planSpec.content;
return (
<div className="space-y-4">
{/* Header with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
selectedMode?.bgColor || "bg-muted"
)}>
{selectedMode && <selectedMode.icon className={cn("h-4 w-4", selectedMode.color)} />}
</div>
<div>
<Label className="text-sm font-medium">Planning Mode</Label>
<p className="text-xs text-muted-foreground">
Choose how much upfront planning before implementation
</p>
</div>
</div>
{/* Quick action buttons when spec/full mode */}
{requiresApproval && hasSpec && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onViewSpec}
className="h-7 px-2"
>
<Eye className="h-3.5 w-3.5 mr-1" />
View
</Button>
</div>
)}
</div>
{/* Mode Selection Cards */}
<div
className={cn(
"grid gap-2",
compact ? "grid-cols-2" : "grid-cols-2 sm:grid-cols-4"
)}
>
{modes.map((m) => {
const isSelected = mode === m.value;
const Icon = m.icon;
return (
<button
key={m.value}
type="button"
onClick={() => onModeChange(m.value)}
data-testid={`${testIdPrefix}-mode-${m.value}`}
className={cn(
"flex flex-col items-center gap-2 p-3 rounded-xl cursor-pointer transition-all duration-200",
"border-2 hover:border-primary/50",
isSelected
? cn("border-primary", m.bgColor)
: "border-border/50 bg-card/50 hover:bg-accent/30"
)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? m.bgColor : "bg-muted"
)}>
<Icon className={cn(
"h-5 w-5 transition-colors",
isSelected ? m.color : "text-muted-foreground"
)} />
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<span className={cn(
"font-medium text-sm",
isSelected ? "text-foreground" : "text-muted-foreground"
)}>
{m.label}
</span>
{m.badge && (
<span className={cn(
"text-[9px] px-1 py-0.5 rounded font-medium",
m.badge === 'Default'
? "bg-emerald-500/15 text-emerald-500"
: "bg-amber-500/15 text-amber-500"
)}>
{m.badge === 'Default' ? 'Default' : 'Review'}
</span>
)}
</div>
{!compact && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">
{m.description}
</p>
)}
</div>
</button>
);
})}
</div>
{/* Require Approval Checkbox - Only show when mode !== 'skip' */}
{mode !== 'skip' && onRequireApprovalChange && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<Checkbox
id="require-approval"
checked={requireApproval}
onCheckedChange={(checked) => onRequireApprovalChange(checked === true)}
data-testid={`${testIdPrefix}-require-approval-checkbox`}
/>
<Label
htmlFor="require-approval"
className="text-sm text-muted-foreground cursor-pointer"
>
Manually approve plan before implementation
</Label>
</div>
)}
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
{requiresApproval && (
<div className={cn(
"rounded-xl border transition-all duration-300",
planSpec?.status === 'approved'
? "border-emerald-500/30 bg-emerald-500/5"
: planSpec?.status === 'generated'
? "border-amber-500/30 bg-amber-500/5"
: "border-border/50 bg-muted/30"
)}>
<div className="p-4 space-y-3">
{/* Status indicator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...</span>
</>
) : planSpec?.status === 'approved' ? (
<>
<Check className="h-4 w-4 text-emerald-500" />
<span className="text-sm text-emerald-500 font-medium">Spec Approved</span>
</>
) : planSpec?.status === 'generated' ? (
<>
<Eye className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-500 font-medium">Spec Ready for Review</span>
</>
) : (
<>
<Sparkles className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Spec will be generated when feature starts
</span>
</>
)}
</div>
{/* Auto-generate toggle area */}
{!planSpec?.status && canGenerate && onGenerateSpec && (
<Button
variant="outline"
size="sm"
onClick={onGenerateSpec}
disabled={isGenerating}
className="h-7"
>
<Sparkles className="h-3.5 w-3.5 mr-1" />
Pre-generate
</Button>
)}
</div>
{/* Spec content preview */}
{hasSpec && (
<div className="space-y-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="w-full justify-between h-8 px-2"
>
<span className="text-xs text-muted-foreground">
{showPreview ? 'Hide Preview' : 'Show Preview'}
</span>
<Eye className="h-3.5 w-3.5" />
</Button>
{showPreview && (
<div className="rounded-lg bg-background/80 border border-border/50 p-3 max-h-48 overflow-y-auto">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
{planSpec.content}
</pre>
</div>
)}
</div>
)}
{/* Action buttons when spec is generated */}
{planSpec?.status === 'generated' && (
<div className="flex items-center gap-2 pt-2 border-t border-border/30">
<Button
variant="outline"
size="sm"
onClick={onRejectSpec}
className="flex-1"
>
Request Changes
</Button>
<Button
size="sm"
onClick={onApproveSpec}
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white"
>
<Check className="h-3.5 w-3.5 mr-1" />
Approve Spec
</Button>
</div>
)}
{/* Regenerate option when approved */}
{planSpec?.status === 'approved' && onGenerateSpec && (
<div className="flex items-center justify-end pt-2 border-t border-border/30">
<Button
variant="ghost"
size="sm"
onClick={onGenerateSpec}
className="h-7"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
Regenerate
</Button>
</div>
)}
</div>
</div>
)}
{/* Info text for non-approval modes */}
{!requiresApproval && (
<p className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
{mode === 'skip'
? "The agent will start implementing immediately without creating a plan or spec."
: "The agent will create a planning outline before implementing, but won't wait for approval."}
</p>
)}
</div>
);
}

View File

@@ -40,6 +40,10 @@ export function SettingsView() {
setMuteDoneSound,
currentProject,
moveProjectToTrash,
defaultPlanningMode,
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
} = useAppStore();
// Convert electron Project to settings-view Project type
@@ -122,10 +126,14 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case "danger":

View File

@@ -1,17 +1,33 @@
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react";
import {
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
export function FeatureDefaultsSection({
@@ -19,10 +35,14 @@ export function FeatureDefaultsSection({
defaultSkipTests,
enableDependencyBlocking,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
return (
<div
@@ -47,6 +67,108 @@ export function FeatureDefaultsSection({
</p>
</div>
<div className="p-6 space-y-5">
{/* Planning Mode Default */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className={cn(
"w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0",
defaultPlanningMode === 'skip' ? "bg-emerald-500/10" :
defaultPlanningMode === 'lite' ? "bg-blue-500/10" :
defaultPlanningMode === 'spec' ? "bg-purple-500/10" :
"bg-amber-500/10"
)}>
{defaultPlanningMode === 'skip' && <Zap className="w-5 h-5 text-emerald-500" />}
{defaultPlanningMode === 'lite' && <ClipboardList className="w-5 h-5 text-blue-500" />}
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
{defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Default Planning Mode
</Label>
<Select
value={defaultPlanningMode}
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
>
<SelectTrigger
className="w-[160px] h-8"
data-testid="default-planning-mode-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-emerald-500" />
<span>Skip</span>
<span className="text-[10px] text-muted-foreground">(Default)</span>
</div>
</SelectItem>
<SelectItem value="lite">
<div className="flex items-center gap-2">
<ClipboardList className="h-3.5 w-3.5 text-blue-500" />
<span>Lite Planning</span>
</div>
</SelectItem>
<SelectItem value="spec">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-purple-500" />
<span>Spec (Lite SDD)</span>
</div>
</SelectItem>
<SelectItem value="full">
<div className="flex items-center gap-2">
<ScrollText className="h-3.5 w-3.5 text-amber-500" />
<span>Full (SDD)</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{defaultPlanningMode === 'skip' && "Jump straight to implementation without upfront planning."}
{defaultPlanningMode === 'lite' && "Create a quick planning outline with tasks before building."}
{defaultPlanningMode === 'spec' && "Generate a specification with acceptance criteria for approval."}
{defaultPlanningMode === 'full' && "Create comprehensive spec with phased implementation plan."}
</p>
</div>
</div>
{/* Require Plan Approval Setting - only show when not skip */}
{defaultPlanningMode !== 'skip' && (
<>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-require-plan-approval"
checked={defaultRequirePlanApproval}
onCheckedChange={(checked) =>
onDefaultRequirePlanApprovalChange(checked === true)
}
className="mt-1"
data-testid="default-require-plan-approval-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-require-plan-approval"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<ShieldCheck className="w-4 h-4 text-brand-500" />
Require manual plan approval by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the agent will pause after generating a plan and wait for you to
review, edit, and approve before starting implementation. You can also view the
plan from the feature card.
</p>
</div>
</div>
<div className="border-t border-border/30" />
</>
)}
{/* Separator */}
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -4,6 +4,11 @@ import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
// Type guard for plan_approval_required event
function isPlanApprovalEvent(event: AutoModeEvent): event is Extract<AutoModeEvent, { type: "plan_approval_required" }> {
return event.type === "plan_approval_required";
}
/**
* Hook for managing auto mode (scoped per project)
*/
@@ -18,6 +23,7 @@ export function useAutoMode() {
addAutoModeActivity,
maxConcurrency,
projects,
setPendingPlanApproval,
} = useAppStore(
useShallow((state) => ({
autoModeByProject: state.autoModeByProject,
@@ -29,6 +35,7 @@ export function useAutoMode() {
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
projects: state.projects,
setPendingPlanApproval: state.setPendingPlanApproval,
}))
);
@@ -147,8 +154,21 @@ export function useAutoMode() {
break;
case "auto_mode_error":
console.error("[AutoMode Error]", event.error);
if (event.featureId && event.error) {
// Check if this is a user-initiated cancellation or abort (not a real error)
if (event.errorType === "cancellation" || event.errorType === "abort") {
// User cancelled/aborted the feature - just log as info, not an error
console.log("[AutoMode] Feature cancelled/aborted:", event.error);
// Remove from running tasks
if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId);
}
break;
}
// Real error - log and show to user
console.error("[AutoMode Error]", event.error);
// Check for authentication errors and provide a more helpful message
const isAuthError =
event.errorType === "authentication" ||
@@ -210,6 +230,124 @@ export function useAutoMode() {
});
}
break;
case "plan_approval_required":
// Plan requires user approval before proceeding
if (isPlanApprovalEvent(event)) {
console.log(
`[AutoMode] Plan approval required for ${event.featureId}`
);
setPendingPlanApproval({
featureId: event.featureId,
projectPath: event.projectPath || currentProject?.path || "",
planContent: event.planContent,
planningMode: event.planningMode,
});
}
break;
case "planning_started":
// Log when planning phase begins
if (event.featureId && event.mode && event.message) {
console.log(
`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`
);
addAutoModeActivity({
featureId: event.featureId,
type: "planning",
message: event.message,
phase: "planning",
});
}
break;
case "plan_approved":
// Log when plan is approved by user
if (event.featureId) {
console.log(`[AutoMode] Plan approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
message: event.hasEdits
? "Plan approved with edits, starting implementation..."
: "Plan approved, starting implementation...",
phase: "action",
});
}
break;
case "plan_auto_approved":
// Log when plan is auto-approved (requirePlanApproval=false)
if (event.featureId) {
console.log(`[AutoMode] Plan auto-approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
message: "Plan auto-approved, starting implementation...",
phase: "action",
});
}
break;
case "plan_revision_requested":
// Log when user requests plan revision with feedback
if (event.featureId) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
console.log(`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`);
addAutoModeActivity({
featureId: event.featureId,
type: "planning",
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
phase: "planning",
});
}
break;
case "auto_mode_task_started":
// Task started - show which task is being worked on
if (event.featureId && "taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
console.log(
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
);
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
});
}
break;
case "auto_mode_task_complete":
// Task completed - show progress
if (event.featureId && "taskId" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
console.log(
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
);
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
message: `${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
});
}
break;
case "auto_mode_phase_complete":
// Phase completed (for full mode with phased tasks)
if (event.featureId && "phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
console.log(
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
);
addAutoModeActivity({
featureId: event.featureId,
type: "action",
message: `Phase ${phaseEvent.phaseNumber} completed`,
phase: "action",
});
}
break;
}
});
@@ -222,6 +360,8 @@ export function useAutoMode() {
setAutoModeRunning,
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
currentProject?.path,
]);
// Restore auto mode for all projects that were running when app was closed

View File

@@ -258,6 +258,13 @@ export interface AutoModeAPI {
featureId: string,
worktreePath?: string
) => Promise<{ success: boolean; error?: string }>;
approvePlan: (
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
) => Promise<{ success: boolean; error?: string }>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}
@@ -1696,6 +1703,23 @@ function createMockAutoModeAPI(): AutoModeAPI {
return { success: true };
},
approvePlan: async (
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
) => {
console.log("[Mock] Plan approval:", {
projectPath,
featureId,
approved,
editedPlan: editedPlan ? "[edited]" : undefined,
feedback,
});
return { success: true };
},
onEvent: (callback: (event: AutoModeEvent) => void) => {
mockAutoModeCallbacks.push(callback);
return () => {

View File

@@ -576,6 +576,20 @@ export class HttpApiClient implements ElectronAPI {
featureId,
worktreePath,
}),
approvePlan: (
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
) =>
this.post("/api/auto-mode/approve-plan", {
projectPath,
featureId,
approved,
editedPlan,
feedback,
}),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",

View File

@@ -263,6 +263,9 @@ export type ModelProvider = "claude";
// Thinking level (budget_tokens) options
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
// Planning mode for feature specifications
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
// AI Provider Profile - user-defined presets for model configurations
export interface AIProfile {
id: string;
@@ -300,6 +303,32 @@ export interface Feature {
worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
}
// Parsed task from spec (for spec and full planning modes)
export interface ParsedTask {
id: string; // e.g., "T001"
description: string; // e.g., "Create user model"
filePath?: string; // e.g., "src/models/user.ts"
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}
// PlanSpec status for feature planning/specification
export interface PlanSpec {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string; // The actual spec/plan markdown content
version: number;
generatedAt?: string; // ISO timestamp
approvedAt?: string; // ISO timestamp
reviewedByUser: boolean; // True if user has seen the spec
tasksCompleted?: number;
tasksTotal?: number;
currentTaskId?: string; // ID of the task currently being worked on
tasks?: ParsedTask[]; // Parsed tasks from the spec
}
// File tree node for project analysis
@@ -460,6 +489,18 @@ export interface AppState {
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
// Plan Approval State
// When a plan requires user approval, this holds the pending approval details
pendingPlanApproval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: "lite" | "spec" | "full";
} | null;
}
// Default background settings for board backgrounds
@@ -689,6 +730,17 @@ export interface AppActions {
setSpecCreatingForProject: (projectPath: string | null) => void;
isSpecCreatingForProject: (projectPath: string) => boolean;
setDefaultPlanningMode: (mode: PlanningMode) => void;
setDefaultRequirePlanApproval: (require: boolean) => void;
// Plan Approval actions
setPendingPlanApproval: (approval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: "lite" | "spec" | "full";
} | null) => void;
// Reset
reset: () => void;
}
@@ -777,6 +829,9 @@ const initialState: AppState = {
defaultFontSize: 14,
},
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
pendingPlanApproval: null,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2185,6 +2240,12 @@ export const useAppStore = create<AppState & AppActions>()(
return get().specCreatingForProject === projectPath;
},
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
// Reset
reset: () => set(initialState),
}),
@@ -2253,6 +2314,8 @@ export const useAppStore = create<AppState & AppActions>()(
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
// Board background settings
boardBackgroundByProject: state.boardBackgroundByProject,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
}),
}
)

View File

@@ -193,7 +193,7 @@ export type AutoModeEvent =
| {
type: "auto_mode_error";
error: string;
errorType?: "authentication" | "execution";
errorType?: "authentication" | "cancellation" | "abort" | "execution";
featureId?: string;
projectId?: string;
projectPath?: string;
@@ -238,6 +238,71 @@ export type AutoModeEvent =
recommendations: string[];
estimatedCost?: number;
estimatedTime?: string;
}
| {
type: "plan_approval_required";
featureId: string;
projectPath?: string;
planContent: string;
planningMode: "lite" | "spec" | "full";
planVersion?: number;
}
| {
type: "plan_auto_approved";
featureId: string;
projectPath?: string;
planContent: string;
planningMode: "lite" | "spec" | "full";
}
| {
type: "plan_approved";
featureId: string;
projectPath?: string;
hasEdits: boolean;
planVersion?: number;
}
| {
type: "plan_rejected";
featureId: string;
projectPath?: string;
feedback?: string;
}
| {
type: "plan_revision_requested";
featureId: string;
projectPath?: string;
feedback?: string;
hasEdits?: boolean;
planVersion?: number;
}
| {
type: "planning_started";
featureId: string;
mode: "lite" | "spec" | "full";
message: string;
}
| {
type: "auto_mode_task_started";
featureId: string;
projectPath?: string;
taskId: string;
taskDescription: string;
taskIndex: number;
tasksTotal: number;
}
| {
type: "auto_mode_task_complete";
featureId: string;
projectPath?: string;
taskId: string;
tasksCompleted: number;
tasksTotal: number;
}
| {
type: "auto_mode_phase_complete";
featureId: string;
projectPath?: string;
phaseNumber: number;
};
export type SpecRegenerationEvent =
@@ -405,6 +470,17 @@ export interface AutoModeAPI {
error?: string;
}>;
approvePlan: (
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
) => Promise<{
success: boolean;
error?: string;
}>;
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
}

View File

@@ -21,6 +21,22 @@ export function isAbortError(error: unknown): boolean {
);
}
/**
* Check if an error is a user-initiated cancellation
*
* @param errorMessage - The error message to check
* @returns True if the error is a user-initiated cancellation
*/
export function isCancellationError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase();
return (
lowerMessage.includes("cancelled") ||
lowerMessage.includes("canceled") ||
lowerMessage.includes("stopped") ||
lowerMessage.includes("aborted")
);
}
/**
* Check if an error is an authentication/API key error
*
@@ -39,7 +55,7 @@ export function isAuthenticationError(errorMessage: string): boolean {
/**
* Error type classification
*/
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
/**
* Classified error information
@@ -49,6 +65,7 @@ export interface ErrorInfo {
message: string;
isAbort: boolean;
isAuth: boolean;
isCancellation: boolean;
originalError: unknown;
}
@@ -62,12 +79,15 @@ export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || "Unknown error");
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
let type: ErrorType;
if (isAuth) {
type = "authentication";
} else if (isAbort) {
type = "abort";
} else if (isCancellation) {
type = "cancellation";
} else if (error instanceof Error) {
type = "execution";
} else {
@@ -79,6 +99,7 @@ export function classifyError(error: unknown): ErrorInfo {
message,
isAbort,
isAuth,
isCancellation,
originalError: error,
};
}

View File

@@ -17,6 +17,7 @@ import { createContextExistsHandler } from "./routes/context-exists.js";
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
import { createApprovePlanHandler } from "./routes/approve-plan.js";
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
@@ -35,6 +36,7 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
createFollowUpFeatureHandler(autoModeService)
);
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
return router;
}

View File

@@ -0,0 +1,78 @@
/**
* POST /approve-plan endpoint - Approve or reject a generated plan/spec
*/
import type { Request, Response } from "express";
import type { AutoModeService } from "../../../services/auto-mode-service.js";
import { createLogger } from "../../../lib/logger.js";
import { getErrorMessage, logError } from "../common.js";
const logger = createLogger("AutoMode");
export function createApprovePlanHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
featureId: string;
approved: boolean;
editedPlan?: string;
feedback?: string;
projectPath?: string;
};
if (!featureId) {
res.status(400).json({
success: false,
error: "featureId is required",
});
return;
}
if (typeof approved !== "boolean") {
res.status(400).json({
success: false,
error: "approved must be a boolean",
});
return;
}
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
// This supports cases where the server restarted while waiting for approval
logger.info(
`[AutoMode] Plan ${approved ? "approved" : "rejected"} for feature ${featureId}${
editedPlan ? " (with edits)" : ""
}${feedback ? ` - Feedback: ${feedback}` : ""}`
);
// Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval(
featureId,
approved,
editedPlan,
feedback,
projectPath
);
if (!result.success) {
res.status(500).json({
success: false,
error: result.error,
});
return;
}
res.json({
success: true,
approved,
message: approved
? "Plan approved - implementation will continue"
: "Plan rejected - feature execution stopped",
});
} catch (error) {
logError(error, "Approve plan failed");
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,24 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown;
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
requirePlanApproval?: boolean;
planSpec?: {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string;
version: number;
generatedAt?: string;
approvedAt?: string;
reviewedByUser: boolean;
tasksCompleted?: number;
tasksTotal?: number;
};
error?: string;
summary?: string;
startedAt?: string;
[key: string]: unknown; // Keep catch-all for extensibility
}
export class FeatureLoader {

View File

@@ -539,4 +539,201 @@ describe("auto-mode-service.ts (integration)", () => {
expect(callCount).toBeGreaterThanOrEqual(1);
}, 15000);
});
describe("planning mode", () => {
it("should execute feature with skip planning mode", async () => {
await createTestFeature(testRepo.path, "skip-plan-feature", {
id: "skip-plan-feature",
category: "test",
description: "Feature with skip planning",
status: "pending",
planningMode: "skip",
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Feature implemented" }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"skip-plan-feature",
false,
false
);
const feature = await featureLoader.get(testRepo.path, "skip-plan-feature");
expect(feature?.status).toBe("waiting_approval");
}, 30000);
it("should execute feature with lite planning mode without approval", async () => {
await createTestFeature(testRepo.path, "lite-plan-feature", {
id: "lite-plan-feature",
category: "test",
description: "Feature with lite planning",
status: "pending",
planningMode: "lite",
requirePlanApproval: false,
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"lite-plan-feature",
false,
false
);
const feature = await featureLoader.get(testRepo.path, "lite-plan-feature");
expect(feature?.status).toBe("waiting_approval");
}, 30000);
it("should emit planning_started event for spec mode", async () => {
await createTestFeature(testRepo.path, "spec-plan-feature", {
id: "spec-plan-feature",
category: "test",
description: "Feature with spec planning",
status: "pending",
planningMode: "spec",
requirePlanApproval: false,
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"spec-plan-feature",
false,
false
);
// Check planning_started event was emitted
const planningEvent = mockEvents.emit.mock.calls.find(
(call) => call[1]?.mode === "spec"
);
expect(planningEvent).toBeTruthy();
}, 30000);
it("should handle feature with full planning mode", async () => {
await createTestFeature(testRepo.path, "full-plan-feature", {
id: "full-plan-feature",
category: "test",
description: "Feature with full planning",
status: "pending",
planningMode: "full",
requirePlanApproval: false,
});
const mockProvider = {
getName: () => "claude",
executeQuery: async function* () {
yield {
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }],
},
};
yield {
type: "result",
subtype: "success",
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(
mockProvider as any
);
await service.executeFeature(
testRepo.path,
"full-plan-feature",
false,
false
);
// Check planning_started event was emitted with full mode
const planningEvent = mockEvents.emit.mock.calls.find(
(call) => call[1]?.mode === "full"
);
expect(planningEvent).toBeTruthy();
}, 30000);
it("should track pending approval correctly", async () => {
// Initially no pending approvals
expect(service.hasPendingApproval("non-existent")).toBe(false);
});
it("should cancel pending approval gracefully", () => {
// Should not throw when cancelling non-existent approval
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow();
});
it("should resolve approval with error for non-existent feature", async () => {
const result = await service.resolvePlanApproval(
"non-existent",
true,
undefined,
undefined,
undefined
);
expect(result.success).toBe(false);
expect(result.error).toContain("No pending approval");
});
});
});

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AutoModeService } from "@/services/auto-mode-service.js";
describe("auto-mode-service.ts - Planning Mode", () => {
let service: AutoModeService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
});
afterEach(async () => {
// Clean up any running processes
await service.stopAutoLoop().catch(() => {});
});
describe("getPlanningPromptPrefix", () => {
// Access private method through any cast for testing
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it("should return empty string for skip mode", () => {
const feature = { id: "test", planningMode: "skip" as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe("");
});
it("should return empty string when planningMode is undefined", () => {
const feature = { id: "test" };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe("");
});
it("should return lite prompt for lite mode without approval", () => {
const feature = {
id: "test",
planningMode: "lite" as const,
requirePlanApproval: false
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Planning Phase (Lite Mode)");
expect(result).toContain("[PLAN_GENERATED]");
expect(result).toContain("Feature Request");
});
it("should return lite_with_approval prompt for lite mode with approval", () => {
const feature = {
id: "test",
planningMode: "lite" as const,
requirePlanApproval: true
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Planning Phase (Lite Mode)");
expect(result).toContain("[SPEC_GENERATED]");
expect(result).toContain("DO NOT proceed with implementation");
});
it("should return spec prompt for spec mode", () => {
const feature = {
id: "test",
planningMode: "spec" as const
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Specification Phase (Spec Mode)");
expect(result).toContain("```tasks");
expect(result).toContain("T001");
expect(result).toContain("[TASK_START]");
expect(result).toContain("[TASK_COMPLETE]");
});
it("should return full prompt for full mode", () => {
const feature = {
id: "test",
planningMode: "full" as const
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Full Specification Phase (Full SDD Mode)");
expect(result).toContain("Phase 1: Foundation");
expect(result).toContain("Phase 2: Core Implementation");
expect(result).toContain("Phase 3: Integration & Testing");
});
it("should include the separator and Feature Request header", () => {
const feature = {
id: "test",
planningMode: "spec" as const
};
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("---");
expect(result).toContain("## Feature Request");
});
it("should instruct agent to NOT output exploration text", () => {
const modes = ["lite", "spec", "full"] as const;
for (const mode of modes) {
const feature = { id: "test", planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Do NOT output exploration text");
expect(result).toContain("Start DIRECTLY");
}
});
});
describe("parseTasksFromSpec (via module)", () => {
// We need to test the module-level function
// Import it directly for testing
it("should parse tasks from a valid tasks block", async () => {
// This tests the internal logic through integration
// The function is module-level, so we verify behavior through the service
const specContent = `
## Specification
\`\`\`tasks
- [ ] T001: Create user model | File: src/models/user.ts
- [ ] T002: Add API endpoint | File: src/routes/users.ts
- [ ] T003: Write unit tests | File: tests/user.test.ts
\`\`\`
`;
// Since parseTasksFromSpec is a module-level function,
// we verify its behavior indirectly through plan parsing
expect(specContent).toContain("T001");
expect(specContent).toContain("T002");
expect(specContent).toContain("T003");
});
it("should handle tasks block with phases", () => {
const specContent = `
\`\`\`tasks
## Phase 1: Setup
- [ ] T001: Initialize project | File: package.json
- [ ] T002: Configure TypeScript | File: tsconfig.json
## Phase 2: Implementation
- [ ] T003: Create main module | File: src/index.ts
\`\`\`
`;
expect(specContent).toContain("Phase 1");
expect(specContent).toContain("Phase 2");
expect(specContent).toContain("T001");
expect(specContent).toContain("T003");
});
});
describe("plan approval flow", () => {
it("should track pending approvals correctly", () => {
expect(service.hasPendingApproval("test-feature")).toBe(false);
});
it("should allow cancelling non-existent approval without error", () => {
expect(() => service.cancelPlanApproval("non-existent")).not.toThrow();
});
it("should return running features count after stop", async () => {
const count = await service.stopAutoLoop();
expect(count).toBe(0);
});
});
describe("resolvePlanApproval", () => {
it("should return error when no pending approval exists", async () => {
const result = await service.resolvePlanApproval(
"non-existent-feature",
true,
undefined,
undefined,
undefined
);
expect(result.success).toBe(false);
expect(result.error).toContain("No pending approval");
});
it("should handle approval with edited plan", async () => {
// Without a pending approval, this should fail gracefully
const result = await service.resolvePlanApproval(
"test-feature",
true,
"Edited plan content",
undefined,
undefined
);
expect(result.success).toBe(false);
});
it("should handle rejection with feedback", async () => {
const result = await service.resolvePlanApproval(
"test-feature",
false,
undefined,
"Please add more details",
undefined
);
expect(result.success).toBe(false);
});
});
describe("buildFeaturePrompt", () => {
const buildFeaturePrompt = (svc: any, feature: any) => {
return svc.buildFeaturePrompt(feature);
};
it("should include feature ID and description", () => {
const feature = {
id: "feat-123",
description: "Add user authentication",
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain("feat-123");
expect(result).toContain("Add user authentication");
});
it("should include specification when present", () => {
const feature = {
id: "feat-123",
description: "Test feature",
spec: "Detailed specification here",
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain("Specification:");
expect(result).toContain("Detailed specification here");
});
it("should include image paths when present", () => {
const feature = {
id: "feat-123",
description: "Test feature",
imagePaths: [
{ path: "/tmp/image1.png", filename: "image1.png", mimeType: "image/png" },
"/tmp/image2.jpg",
],
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain("Context Images Attached");
expect(result).toContain("image1.png");
expect(result).toContain("/tmp/image2.jpg");
});
it("should include summary tags instruction", () => {
const feature = {
id: "feat-123",
description: "Test feature",
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain("<summary>");
expect(result).toContain("</summary>");
});
});
describe("extractTitleFromDescription", () => {
const extractTitle = (svc: any, description: string) => {
return svc.extractTitleFromDescription(description);
};
it("should return 'Untitled Feature' for empty description", () => {
expect(extractTitle(service, "")).toBe("Untitled Feature");
expect(extractTitle(service, " ")).toBe("Untitled Feature");
});
it("should return first line if under 60 characters", () => {
const description = "Add user login\nWith email validation";
expect(extractTitle(service, description)).toBe("Add user login");
});
it("should truncate long first lines to 60 characters", () => {
const description = "This is a very long feature description that exceeds the sixty character limit significantly";
const result = extractTitle(service, description);
expect(result.length).toBe(60);
expect(result).toContain("...");
});
});
describe("PLANNING_PROMPTS structure", () => {
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it("should have all required planning modes", () => {
const modes = ["lite", "spec", "full"] as const;
for (const mode of modes) {
const feature = { id: "test", planningMode: mode };
const result = getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100);
}
});
it("lite prompt should include correct structure", () => {
const feature = { id: "test", planningMode: "lite" as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Goal");
expect(result).toContain("Approach");
expect(result).toContain("Files to Touch");
expect(result).toContain("Tasks");
expect(result).toContain("Risks");
});
it("spec prompt should include task format instructions", () => {
const feature = { id: "test", planningMode: "spec" as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Problem");
expect(result).toContain("Solution");
expect(result).toContain("Acceptance Criteria");
expect(result).toContain("GIVEN-WHEN-THEN");
expect(result).toContain("Implementation Tasks");
expect(result).toContain("Verification");
});
it("full prompt should include phases", () => {
const feature = { id: "test", planningMode: "full" as const };
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain("Problem Statement");
expect(result).toContain("User Story");
expect(result).toContain("Technical Context");
expect(result).toContain("Non-Goals");
expect(result).toContain("Phase 1");
expect(result).toContain("Phase 2");
expect(result).toContain("Phase 3");
});
});
describe("status management", () => {
it("should report correct status", () => {
const status = service.getStatus();
expect(status.autoLoopRunning).toBe(false);
expect(status.runningFeatures).toEqual([]);
expect(status.isRunning).toBe(false);
});
});
});

View File

@@ -0,0 +1,345 @@
import { describe, it, expect } from "vitest";
/**
* Test the task parsing logic by reimplementing the parsing functions
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
*/
interface ParsedTask {
id: string;
description: string;
filePath?: string;
phase?: string;
status: 'pending' | 'in_progress' | 'completed';
}
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
// Match pattern: - [ ] T###: Description | File: path
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
if (!taskMatch) {
// Try simpler pattern without file
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
if (simpleMatch) {
return {
id: simpleMatch[1],
description: simpleMatch[2].trim(),
phase: currentPhase,
status: 'pending',
};
}
return null;
}
return {
id: taskMatch[1],
description: taskMatch[2].trim(),
filePath: taskMatch[3]?.trim(),
phase: currentPhase,
status: 'pending',
};
}
function parseTasksFromSpec(specContent: string): ParsedTask[] {
const tasks: ParsedTask[] = [];
// Extract content within ```tasks ... ``` block
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
if (!tasksBlockMatch) {
// Try fallback: look for task lines anywhere in content
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
if (!taskLines) {
return tasks;
}
// Parse fallback task lines
let currentPhase: string | undefined;
for (const line of taskLines) {
const parsed = parseTaskLine(line, currentPhase);
if (parsed) {
tasks.push(parsed);
}
}
return tasks;
}
const tasksContent = tasksBlockMatch[1];
const lines = tasksContent.split('\n');
let currentPhase: string | undefined;
for (const line of lines) {
const trimmedLine = line.trim();
// Check for phase header (e.g., "## Phase 1: Foundation")
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
if (phaseMatch) {
currentPhase = phaseMatch[1].trim();
continue;
}
// Check for task line
if (trimmedLine.startsWith('- [ ]')) {
const parsed = parseTaskLine(trimmedLine, currentPhase);
if (parsed) {
tasks.push(parsed);
}
}
}
return tasks;
}
describe("Task Parsing", () => {
describe("parseTaskLine", () => {
it("should parse task with file path", () => {
const line = "- [ ] T001: Create user model | File: src/models/user.ts";
const result = parseTaskLine(line);
expect(result).toEqual({
id: "T001",
description: "Create user model",
filePath: "src/models/user.ts",
phase: undefined,
status: "pending",
});
});
it("should parse task without file path", () => {
const line = "- [ ] T002: Setup database connection";
const result = parseTaskLine(line);
expect(result).toEqual({
id: "T002",
description: "Setup database connection",
phase: undefined,
status: "pending",
});
});
it("should include phase when provided", () => {
const line = "- [ ] T003: Write tests | File: tests/user.test.ts";
const result = parseTaskLine(line, "Phase 1: Foundation");
expect(result?.phase).toBe("Phase 1: Foundation");
});
it("should return null for invalid line", () => {
expect(parseTaskLine("- [ ] Invalid format")).toBeNull();
expect(parseTaskLine("Not a task line")).toBeNull();
expect(parseTaskLine("")).toBeNull();
});
it("should handle multi-word descriptions", () => {
const line = "- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts";
const result = parseTaskLine(line);
expect(result?.description).toBe("Implement user authentication with JWT tokens");
});
it("should trim whitespace from description and file path", () => {
const line = "- [ ] T005: Create API endpoint | File: src/routes/api.ts ";
const result = parseTaskLine(line);
expect(result?.description).toBe("Create API endpoint");
expect(result?.filePath).toBe("src/routes/api.ts");
});
});
describe("parseTasksFromSpec", () => {
it("should parse tasks from a tasks code block", () => {
const specContent = `
## Specification
Some description here.
\`\`\`tasks
- [ ] T001: Create user model | File: src/models/user.ts
- [ ] T002: Add API endpoint | File: src/routes/users.ts
- [ ] T003: Write unit tests | File: tests/user.test.ts
\`\`\`
## Notes
Some notes here.
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(3);
expect(tasks[0].id).toBe("T001");
expect(tasks[1].id).toBe("T002");
expect(tasks[2].id).toBe("T003");
});
it("should parse tasks with phases", () => {
const specContent = `
\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: Initialize project | File: package.json
- [ ] T002: Configure TypeScript | File: tsconfig.json
## Phase 2: Implementation
- [ ] T003: Create main module | File: src/index.ts
- [ ] T004: Add utility functions | File: src/utils.ts
## Phase 3: Testing
- [ ] T005: Write tests | File: tests/index.test.ts
\`\`\`
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(5);
expect(tasks[0].phase).toBe("Phase 1: Foundation");
expect(tasks[1].phase).toBe("Phase 1: Foundation");
expect(tasks[2].phase).toBe("Phase 2: Implementation");
expect(tasks[3].phase).toBe("Phase 2: Implementation");
expect(tasks[4].phase).toBe("Phase 3: Testing");
});
it("should return empty array for content without tasks", () => {
const specContent = "Just some text without any tasks";
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toEqual([]);
});
it("should fallback to finding task lines outside code block", () => {
const specContent = `
## Implementation Plan
- [ ] T001: First task | File: src/first.ts
- [ ] T002: Second task | File: src/second.ts
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(2);
expect(tasks[0].id).toBe("T001");
expect(tasks[1].id).toBe("T002");
});
it("should handle empty tasks block", () => {
const specContent = `
\`\`\`tasks
\`\`\`
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toEqual([]);
});
it("should handle mixed valid and invalid lines", () => {
const specContent = `
\`\`\`tasks
- [ ] T001: Valid task | File: src/valid.ts
- Invalid line
Some other text
- [ ] T002: Another valid task
\`\`\`
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(2);
});
it("should preserve task order", () => {
const specContent = `
\`\`\`tasks
- [ ] T003: Third
- [ ] T001: First
- [ ] T002: Second
\`\`\`
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks[0].id).toBe("T003");
expect(tasks[1].id).toBe("T001");
expect(tasks[2].id).toBe("T002");
});
it("should handle task IDs with different numbers", () => {
const specContent = `
\`\`\`tasks
- [ ] T001: First
- [ ] T010: Tenth
- [ ] T100: Hundredth
\`\`\`
`;
const tasks = parseTasksFromSpec(specContent);
expect(tasks).toHaveLength(3);
expect(tasks[0].id).toBe("T001");
expect(tasks[1].id).toBe("T010");
expect(tasks[2].id).toBe("T100");
});
});
describe("spec content generation patterns", () => {
it("should match the expected lite mode output format", () => {
const liteModeOutput = `
1. **Goal**: Implement user registration
2. **Approach**: Create form component, add validation, connect to API
3. **Files to Touch**: src/components/Register.tsx, src/api/auth.ts
4. **Tasks**:
1. Create registration form
2. Add form validation
3. Connect to backend API
5. **Risks**: Form state management complexity
[PLAN_GENERATED] Planning outline complete.
`;
expect(liteModeOutput).toContain("[PLAN_GENERATED]");
expect(liteModeOutput).toContain("Goal");
expect(liteModeOutput).toContain("Approach");
});
it("should match the expected spec mode output format", () => {
const specModeOutput = `
1. **Problem**: Users cannot register for accounts
2. **Solution**: Implement registration form with email/password validation
3. **Acceptance Criteria**:
- GIVEN a new user, WHEN they fill in valid details, THEN account is created
4. **Files to Modify**:
| File | Purpose | Action |
|------|---------|--------|
| src/Register.tsx | Registration form | create |
5. **Implementation Tasks**:
\`\`\`tasks
- [ ] T001: Create registration component | File: src/Register.tsx
- [ ] T002: Add form validation | File: src/Register.tsx
\`\`\`
6. **Verification**: Manual testing of registration flow
[SPEC_GENERATED] Please review the specification above.
`;
expect(specModeOutput).toContain("[SPEC_GENERATED]");
expect(specModeOutput).toContain("```tasks");
expect(specModeOutput).toContain("T001");
});
it("should match the expected full mode output format", () => {
const fullModeOutput = `
1. **Problem Statement**: Users need ability to create accounts
2. **User Story**: As a new user, I want to register, so that I can access the app
3. **Acceptance Criteria**:
- **Happy Path**: GIVEN valid email, WHEN registering, THEN account created
- **Edge Cases**: GIVEN existing email, WHEN registering, THEN error shown
4. **Technical Context**:
| Aspect | Value |
|--------|-------|
| Affected Files | src/Register.tsx |
5. **Non-Goals**: Social login, password recovery
6. **Implementation Tasks**:
\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: Setup component structure | File: src/Register.tsx
## Phase 2: Core Implementation
- [ ] T002: Add form logic | File: src/Register.tsx
## Phase 3: Integration & Testing
- [ ] T003: Connect to API | File: src/api/auth.ts
\`\`\`
[SPEC_GENERATED] Please review the comprehensive specification above.
`;
expect(fullModeOutput).toContain("Phase 1");
expect(fullModeOutput).toContain("Phase 2");
expect(fullModeOutput).toContain("Phase 3");
expect(fullModeOutput).toContain("[SPEC_GENERATED]");
});
});
});

1246
package-lock.json generated

File diff suppressed because it is too large Load Diff