Merge branch 'main' into feature/worktrees

This commit is contained in:
Cody Seibert
2025-12-16 12:14:05 -05:00
51 changed files with 2949 additions and 715 deletions

View File

@@ -82,8 +82,8 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
medium: "Med", //
high: "High", //
ultrathink: "Ultra",
};
return labels[level];
@@ -323,6 +323,49 @@ export const KanbanCard = memo(function KanbanCard({
/>
)}
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
"top-2 left-2 min-w-[36px]",
feature.priority === 1 &&
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
feature.priority === 2 &&
"bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50",
feature.priority === 3 &&
"bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
)}
data-testid={`priority-badge-${feature.id}`}
>
P{feature.priority}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Category text next to priority badge */}
{feature.priority && (
<div className="absolute top-2 left-[54px] right-12 z-10 flex items-center h-[32px]">
<span className="text-[11px] text-muted-foreground/70 font-medium truncate">
{feature.category}
</span>
</div>
)}
{/* Skip Tests (Manual) indicator badge */}
{feature.skipTests && !feature.error && (
<TooltipProvider delayDuration={200}>
@@ -331,7 +374,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
)}
data-testid={`skip-tests-badge-${feature.id}`}
@@ -354,7 +397,7 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
"top-2 left-2",
feature.priority ? "top-11 left-2" : "top-2 left-2",
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
@@ -374,7 +417,11 @@ export const KanbanCard = memo(function KanbanCard({
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
feature.priority
? "top-11 left-2"
: feature.skipTests
? "top-8 left-2"
: "top-2 left-2",
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
"animate-pulse"
)}
@@ -394,9 +441,11 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10 cursor-default",
"bg-[var(--status-info-bg)] border border-[var(--status-info)]/40 text-[var(--status-info)]",
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
feature.priority
? "top-11 left-2"
: feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
@@ -415,7 +464,10 @@ export const KanbanCard = memo(function KanbanCard({
<CardHeader
className={cn(
"p-3 pb-2 block",
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
feature.priority && "pt-12",
!feature.priority &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-10",
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
@@ -606,9 +658,11 @@ export const KanbanCard = memo(function KanbanCard({
)}
</button>
)}
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
{feature.category}
</CardDescription>
{!feature.priority && (
<CardDescription className="text-[11px] mt-1.5 truncate text-muted-foreground/70">
{feature.category}
</CardDescription>
)}
</div>
</div>
</CardHeader>
@@ -901,7 +955,7 @@ export const KanbanCard = memo(function KanbanCard({
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="ghost"
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {

View File

@@ -20,7 +20,15 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
useAppStore,
@@ -34,7 +42,14 @@ import {
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface AddFeatureDialogProps {
open: boolean;
@@ -49,6 +64,7 @@ interface AddFeatureDialogProps {
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -79,11 +95,19 @@ export function AddFeatureDialog({
model: "opus" as AgentModel,
thinkingLevel: "none" as ThinkingLevel,
branchName: "main",
priority: 2 as number, // Default to medium priority
});
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [descriptionError, setDescriptionError] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
// Get enhancement model from store
const { enhancementModel } = useAppStore();
// Sync skipTests default when dialog opens
useEffect(() => {
@@ -117,6 +141,7 @@ export function AddFeatureDialog({
model: selectedModel,
thinkingLevel: normalizedThinking,
branchName: newFeature.branchName,
priority: newFeature.priority,
});
// Reset form
@@ -128,6 +153,7 @@ export function AddFeatureDialog({
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus",
priority: 2,
thinkingLevel: "none",
branchName: "main",
});
@@ -146,6 +172,33 @@ export function AddFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!newFeature.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
newFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const handleModelSelect = (model: AgentModel) => {
setNewFeature({
...newFeature,
@@ -156,7 +209,10 @@ export function AddFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
setNewFeature({
...newFeature,
model,
@@ -210,7 +266,10 @@ export function AddFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
@@ -232,6 +291,58 @@ export function AddFeatureDialog({
error={descriptionError}
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[200px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!newFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
@@ -256,13 +367,26 @@ export function AddFeatureDialog({
data-testid="feature-branch-input"
/>
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if needed.
Work will be done in this branch. A worktree will be created if
needed.
</p>
</div>
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) =>
setNewFeature({ ...newFeature, priority })
}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
@@ -325,16 +449,17 @@ export function AddFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) =>
setNewFeature({ ...newFeature, skipTests })
}
steps={newFeature.steps}
onStepsChange={(steps) =>
setNewFeature({ ...newFeature, steps })
}
onStepsChange={(steps) => setNewFeature({ ...newFeature, steps })}
/>
</TabsContent>
</Tabs>

View File

@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Feature } from "@/store/app-store";
import { AlertCircle, CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
interface DependencyTreeDialogProps {
open: boolean;
onClose: () => void;
feature: Feature | null;
allFeatures: Feature[];
}
export function DependencyTreeDialog({
open,
onClose,
feature,
allFeatures,
}: DependencyTreeDialogProps) {
const [dependencyTree, setDependencyTree] = useState<{
dependencies: Feature[];
dependents: Feature[];
}>({ dependencies: [], dependents: [] });
useEffect(() => {
if (!feature) return;
// Find features this depends on
const dependencies = (feature.dependencies || [])
.map((depId) => allFeatures.find((f) => f.id === depId))
.filter((f): f is Feature => f !== undefined);
// Find features that depend on this one
const dependents = allFeatures.filter((f) =>
f.dependencies?.includes(feature.id)
);
setDependencyTree({ dependencies, dependents });
}, [feature, allFeatures]);
if (!feature) return null;
const getStatusIcon = (status: Feature["status"]) => {
switch (status) {
case "completed":
case "verified":
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in_progress":
case "waiting_approval":
return <Circle className="w-4 h-4 text-blue-500 fill-blue-500/20" />;
default:
return <Circle className="w-4 h-4 text-muted-foreground/50" />;
}
};
const getPriorityBadge = (priority?: number) => {
if (!priority) return null;
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-medium",
priority === 1 && "bg-red-500/20 text-red-500",
priority === 2 && "bg-yellow-500/20 text-yellow-500",
priority === 3 && "bg-blue-500/20 text-blue-500"
)}
>
P{priority}
</span>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Dependency Tree</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* Current Feature */}
<div className="border-2 border-primary rounded-lg p-4 bg-primary/5">
<div className="flex items-center gap-3 mb-2">
{getStatusIcon(feature.status)}
<h3 className="font-semibold text-sm">Current Feature</h3>
{getPriorityBadge(feature.priority)}
</div>
<p className="text-sm text-muted-foreground">{feature.description}</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Category: {feature.category}
</p>
</div>
{/* Dependencies (what this feature needs) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependencies ({dependencyTree.dependencies.length})
</h3>
<span className="text-xs text-muted-foreground">
This feature requires:
</span>
</div>
{dependencyTree.dependencies.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependencies - this feature can be started independently
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependencies.map((dep) => (
<div
key={dep.id}
className={cn(
"border rounded-lg p-3 transition-colors",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/5 border-green-500/20"
: "bg-muted/30 border-border"
)}
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dep.status)}
<span className="text-sm font-medium flex-1">
{dep.description.slice(0, 100)}
{dep.description.length > 100 && "..."}
</span>
{getPriorityBadge(dep.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dep.category}
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dep.status === "completed" || dep.status === "verified"
? "bg-green-500/20 text-green-600"
: dep.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
)}
>
{dep.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Dependents (what depends on this feature) */}
<div>
<div className="flex items-center gap-2 mb-3">
<h3 className="font-semibold text-sm">
Dependents ({dependencyTree.dependents.length})
</h3>
<span className="text-xs text-muted-foreground">
Features blocked by this:
</span>
</div>
{dependencyTree.dependents.length === 0 ? (
<div className="text-sm text-muted-foreground/70 italic border border-dashed rounded-lg p-4 text-center">
No dependents - no other features are waiting on this one
</div>
) : (
<div className="space-y-2">
{dependencyTree.dependents.map((dependent) => (
<div
key={dependent.id}
className="border rounded-lg p-3 bg-muted/30"
>
<div className="flex items-center gap-3 mb-1">
{getStatusIcon(dependent.status)}
<span className="text-sm font-medium flex-1">
{dependent.description.slice(0, 100)}
{dependent.description.length > 100 && "..."}
</span>
{getPriorityBadge(dependent.priority)}
</div>
<div className="flex items-center gap-3 ml-7">
<span className="text-xs text-muted-foreground">
{dependent.category}
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
dependent.status === "completed" ||
dependent.status === "verified"
? "bg-green-500/20 text-green-600"
: dependent.status === "in_progress"
? "bg-blue-500/20 text-blue-600"
: "bg-muted text-muted-foreground"
)}
>
{dependent.status.replace(/_/g, " ")}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Warning for incomplete dependencies */}
{dependencyTree.dependencies.some(
(d) => d.status !== "completed" && d.status !== "verified"
) && (
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-700 dark:text-yellow-500">
Incomplete Dependencies
</p>
<p className="text-yellow-600 dark:text-yellow-400 mt-1">
This feature has dependencies that aren't completed yet.
Consider completing them first for a smoother implementation.
</p>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -20,20 +20,38 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
import { MessageSquare, Settings2, FlaskConical } from "lucide-react";
import {
MessageSquare,
Settings2,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
import {
Feature,
AgentModel,
ThinkingLevel,
AIProfile,
useAppStore,
} from "@/store/app-store";
import {
ModelSelector,
ThinkingLevelSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
} from "../shared";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -49,6 +67,7 @@ interface EditFeatureDialogProps {
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => void;
categorySuggestions: string[];
@@ -56,6 +75,7 @@ interface EditFeatureDialogProps {
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
allFeatures: Feature[];
}
export function EditFeatureDialog({
@@ -67,11 +87,20 @@ export function EditFeatureDialog({
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
const [showDependencyTree, setShowDependencyTree] = useState(false);
// Get enhancement model from store
const { enhancementModel } = useAppStore();
useEffect(() => {
setEditingFeature(feature);
@@ -85,8 +114,10 @@ export function EditFeatureDialog({
if (!editingFeature) return;
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? "none")
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const updates = {
@@ -98,6 +129,7 @@ export function EditFeatureDialog({
thinkingLevel: normalizedThinking,
imagePaths: editingFeature.imagePaths ?? [],
branchName: editingFeature.branchName ?? "main",
priority: editingFeature.priority ?? 2,
};
onUpdate(editingFeature.id, updates);
@@ -123,7 +155,10 @@ export function EditFeatureDialog({
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -132,6 +167,35 @@ export function EditFeatureDialog({
});
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementModel
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
} else {
toast.error(result?.error || "Failed to enhance description");
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
} finally {
setIsEnhancing(false);
}
};
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
if (!editingFeature) {
@@ -180,7 +244,10 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -204,6 +271,58 @@ export function EditFeatureDialog({
data-testid="edit-feature-description"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
<CategoryAutocomplete
@@ -241,14 +360,30 @@ export function EditFeatureDialog({
)}
{editingFeature.status === "backlog" && (
<p className="text-xs text-muted-foreground">
Work will be done in this branch. A worktree will be created if needed.
Work will be done in this branch. A worktree will be created
if needed.
</p>
)}
</div>
{/* Priority Selector */}
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
@@ -314,7 +449,10 @@ export function EditFeatureDialog({
</TabsContent>
{/* Testing Tab */}
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
<TabsContent
value="testing"
className="space-y-4 overflow-y-auto cursor-default"
>
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
@@ -328,20 +466,37 @@ export function EditFeatureDialog({
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
<DialogFooter className="sm:!justify-between">
<Button
variant="outline"
onClick={() => setShowDependencyTree(true)}
className="gap-2 h-10"
>
Save Changes
</HotkeyButton>
<GitBranch className="w-4 h-4" />
View Dependency Tree
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
>
Save Changes
</HotkeyButton>
</div>
</DialogFooter>
</DialogContent>
<DependencyTreeDialog
open={showDependencyTree}
onClose={() => setShowDependencyTree(false)}
feature={editingFeature}
allFeatures={allFeatures}
/>
</Dialog>
);
}

View File

@@ -239,6 +239,7 @@ export function FeatureSuggestionsDialog({
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
// Create each new feature using the features API

View File

@@ -1,5 +1,11 @@
import { useCallback } from "react";
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
import {
Feature,
FeatureImage,
AgentModel,
ThinkingLevel,
useAppStore,
} from "@/store/app-store";
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
@@ -12,7 +18,10 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -57,7 +66,13 @@ export function useBoardActions({
projectPath,
onWorktreeCreated,
}: UseBoardActionsProps) {
const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore();
const {
addFeature,
updateFeature,
removeFeature,
moveFeature,
useWorktrees,
} = useAppStore();
const autoMode = useAutoMode();
/**
@@ -98,9 +113,13 @@ export function useBoardActions({
}
return result.worktree.path;
} else {
console.error("[BoardActions] Failed to create worktree:", result.error);
console.error(
"[BoardActions] Failed to create worktree:",
result.error
);
toast.error("Failed to create worktree", {
description: result.error || "Could not create worktree for this branch.",
description:
result.error || "Could not create worktree for this branch.",
});
return projectPath; // Fall back to project path
}
@@ -126,6 +145,7 @@ export function useBoardActions({
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
}) => {
const newFeatureData = {
...featureData,
@@ -150,6 +170,7 @@ export function useBoardActions({
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
}
) => {
updateFeature(featureId, updates);
@@ -173,7 +194,9 @@ export function useBoardActions({
try {
await autoMode.stopFeature(featureId);
toast.success("Agent stopped", {
description: `Stopped and deleted: ${truncateDescription(feature.description)}`,
description: `Stopped and deleted: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature before delete:", error);
@@ -191,11 +214,17 @@ export function useBoardActions({
await api.deleteFile(imagePathObj.path);
console.log(`[Board] Deleted image: ${imagePathObj.path}`);
} catch (error) {
console.error(`[Board] Failed to delete image ${imagePathObj.path}:`, error);
console.error(
`[Board] Failed to delete image ${imagePathObj.path}:`,
error
);
}
}
} catch (error) {
console.error(`[Board] Error deleting images for feature ${featureId}:`, error);
console.error(
`[Board] Error deleting images for feature ${featureId}:`,
error
);
}
}
@@ -228,7 +257,10 @@ export function useBoardActions({
);
if (result.success) {
console.log("[Board] Feature run started successfully in worktree:", featureWorktreePath || "main");
console.log(
"[Board] Feature run started successfully in worktree:",
featureWorktreePath || "main"
);
} else {
console.error("[Board] Failed to run feature:", result.error);
await loadFeatures();
@@ -276,7 +308,10 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
const result = await api.autoMode.verifyFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature verification started successfully");
@@ -303,7 +338,10 @@ export function useBoardActions({
return;
}
const result = await api.autoMode.resumeFeature(currentProject.path, feature.id);
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
@@ -327,7 +365,9 @@ export function useBoardActions({
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${truncateDescription(feature.description)}`,
description: `Marked as verified: ${truncateDescription(
feature.description
)}`,
});
},
[moveFeature, persistFeatureUpdate]
@@ -342,7 +382,9 @@ export function useBoardActions({
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
description: `Moved back to In Progress: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -355,7 +397,12 @@ export function useBoardActions({
setFollowUpImagePaths([]);
setShowFollowUpDialog(true);
},
[setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog]
[
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setShowFollowUpDialog,
]
);
const handleSendFollowUp = useCallback(async () => {
@@ -388,19 +435,28 @@ export function useBoardActions({
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(featureDescription)}`,
});
toast.success("Follow-up started", {
description: `Continuing work on: ${truncateDescription(
featureDescription
)}`,
});
const imagePaths = followUpImagePaths.map((img) => img.path);
// Use the feature's worktreePath to ensure work happens in the correct branch
const featureWorktreePath = followUpFeature.worktreePath;
api.autoMode
.followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, imagePaths, featureWorktreePath)
.followUpFeature(
currentProject.path,
followUpFeature.id,
followUpPrompt,
imagePaths,
featureWorktreePath
)
.catch((error) => {
console.error("[Board] Error sending follow-up:", error);
toast.error("Failed to send follow-up", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
loadFeatures();
});
@@ -428,19 +484,26 @@ export function useBoardActions({
if (!api?.autoMode?.commitFeature) {
console.error("Commit feature API not available");
toast.error("Commit not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
// Pass the feature's worktreePath to ensure commits happen in the correct worktree
const result = await api.autoMode.commitFeature(currentProject.path, feature.id, feature.worktreePath);
const result = await api.autoMode.commitFeature(
currentProject.path,
feature.id,
feature.worktreePath
);
if (result.success) {
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
description: `Committed and verified: ${truncateDescription(
feature.description
)}`,
});
// Refresh worktree selector to update commit counts
onWorktreeCreated?.();
@@ -454,12 +517,19 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error committing feature:", error);
toast.error("Failed to commit feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
[
currentProject,
moveFeature,
persistFeatureUpdate,
loadFeatures,
onWorktreeCreated,
]
);
const handleRevertFeature = useCallback(
@@ -471,17 +541,23 @@ export function useBoardActions({
if (!api?.worktree?.revertFeature) {
console.error("Worktree API not available");
toast.error("Revert not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.revertFeature(currentProject.path, feature.id);
const result = await api.worktree.revertFeature(
currentProject.path,
feature.id
);
if (result.success) {
await loadFeatures();
toast.success("Feature reverted", {
description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`,
description: `All changes discarded. Moved back to backlog: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to revert feature:", result.error);
@@ -492,7 +568,8 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error reverting feature:", error);
toast.error("Failed to revert feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -508,17 +585,23 @@ export function useBoardActions({
if (!api?.worktree?.mergeFeature) {
console.error("Worktree API not available");
toast.error("Merge not available", {
description: "This feature is not available in the current version.",
description:
"This feature is not available in the current version.",
});
return;
}
const result = await api.worktree.mergeFeature(currentProject.path, feature.id);
const result = await api.worktree.mergeFeature(
currentProject.path,
feature.id
);
if (result.success) {
await loadFeatures();
toast.success("Feature merged", {
description: `Changes merged to main branch: ${truncateDescription(feature.description)}`,
description: `Changes merged to main branch: ${truncateDescription(
feature.description
)}`,
});
} else {
console.error("[Board] Failed to merge feature:", result.error);
@@ -529,7 +612,8 @@ export function useBoardActions({
} catch (error) {
console.error("[Board] Error merging feature:", error);
toast.error("Failed to merge feature", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -560,7 +644,9 @@ export function useBoardActions({
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
description: `Moved back to verified: ${truncateDescription(
feature.description
)}`,
});
},
[updateFeature, persistFeatureUpdate]
@@ -589,7 +675,12 @@ export function useBoardActions({
setOutputFeature(targetFeature);
}
},
[inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature]
[
inProgressFeaturesForShortcuts,
outputFeature?.id,
setShowOutputModal,
setOutputFeature,
]
);
const handleForceStopFeature = useCallback(
@@ -610,13 +701,18 @@ export function useBoardActions({
toast.success("Agent stopped", {
description:
targetStatus === "waiting_approval"
? `Stopped commit - returned to waiting approval: ${truncateDescription(feature.description)}`
: `Stopped working on: ${truncateDescription(feature.description)}`,
? `Stopped commit - returned to waiting approval: ${truncateDescription(
feature.description
)}`
: `Stopped working on: ${truncateDescription(
feature.description
)}`,
});
} catch (error) {
console.error("[Board] Error stopping feature:", error);
toast.error("Failed to stop agent", {
description: error instanceof Error ? error.message : "An error occurred",
description:
error instanceof Error ? error.message : "An error occurred",
});
}
},
@@ -656,9 +752,19 @@ export function useBoardActions({
onWorktreeCreated?.();
// Start the implementation
// Pass feature with worktreePath so handleRunFeature uses the correct path
await handleStartImplementation({ ...feature, worktreePath: worktreePath || undefined });
await handleStartImplementation({
...feature,
worktreePath: worktreePath || undefined,
});
}
}, [features, runningAutoTasks, handleStartImplementation, getOrCreateWorktreeForFeature, persistFeatureUpdate, onWorktreeCreated]);
}, [
features,
runningAutoTasks,
handleStartImplementation,
getOrCreateWorktreeForFeature,
persistFeatureUpdate,
onWorktreeCreated,
]);
const handleDeleteAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === "verified");
@@ -669,10 +775,7 @@ export function useBoardActions({
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
console.error(
"[Board] Error stopping feature before delete:",
error
);
console.error("[Board] Error stopping feature before delete:", error);
}
}
removeFeature(feature.id);
@@ -682,7 +785,13 @@ export function useBoardActions({
toast.success("All verified features deleted", {
description: `Deleted ${verifiedFeatures.length} feature(s).`,
});
}, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]);
}, [
features,
runningAutoTasks,
autoMode,
removeFeature,
persistFeatureDelete,
]);
return {
handleAddFeature,

View File

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

View File

@@ -0,0 +1,63 @@
"use client";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface PrioritySelectorProps {
selectedPriority: number;
onPrioritySelect: (priority: number) => void;
testIdPrefix?: string;
}
export function PrioritySelector({
selectedPriority,
onPrioritySelect,
testIdPrefix = "priority",
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 1
? "bg-red-500/20 text-red-500 border-2 border-red-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-high-button`}
>
High
</button>
<button
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 2
? "bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-medium-button`}
>
Medium
</button>
<button
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
"flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
selectedPriority === 3
? "bg-blue-500/20 text-blue-500 border-2 border-blue-500/50"
: "bg-muted/50 text-muted-foreground border border-border hover:bg-muted"
)}
data-testid={`${testIdPrefix}-low-button`}
>
Low
</button>
</div>
</div>
);
}