Merge main into worktree-confusion: resolve conflicts maintaining both sets of changes

This commit is contained in:
Cody Seibert
2025-12-17 23:01:48 -05:00
41 changed files with 4831 additions and 717 deletions

View File

@@ -32,6 +32,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";
@@ -70,6 +71,9 @@ export function BoardView() {
setKanbanCardDetailLevel,
specCreatingForProject,
setSpecCreatingForProject,
pendingPlanApproval,
setPendingPlanApproval,
updateFeature,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
@@ -102,6 +106,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] =
@@ -151,6 +157,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;
@@ -628,6 +636,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
@@ -750,6 +882,8 @@ export function BoardView() {
onCommit={handleCommitFeature}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
@@ -863,6 +997,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,
@@ -44,6 +46,7 @@ import {
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
@@ -66,6 +69,8 @@ interface AddFeatureDialogProps {
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -111,9 +116,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(() => {
@@ -124,8 +131,10 @@ export function AddFeatureDialog({
branchName: defaultBranch || "",
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
}
}, [open, defaultSkipTests, defaultBranch]);
}, [open, defaultSkipTests, defaultBranch, defaultPlanningMode, defaultRequirePlanApproval]);
const handleAdd = () => {
if (!newFeature.description.trim()) {
@@ -162,6 +171,8 @@ export function AddFeatureDialog({
thinkingLevel: normalizedThinking,
branchName: finalBranchName,
priority: newFeature.priority,
planningMode,
requirePlanApproval,
});
// Reset form
@@ -178,6 +189,8 @@ export function AddFeatureDialog({
branchName: "",
});
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setNewFeaturePreviewMap(new Map());
setShowAdvancedOptions(false);
setDescriptionError(false);
@@ -248,13 +261,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();
@@ -280,9 +293,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>
@@ -466,11 +479,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,
@@ -45,6 +47,7 @@ import {
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
import {
DropdownMenu,
@@ -69,6 +72,8 @@ interface EditFeatureDialogProps {
imagePaths: DescriptionImagePath[];
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
) => void;
categorySuggestions: string[];
@@ -105,18 +110,22 @@ 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) {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
} else {
if (feature) {
setPlanningMode(feature.planningMode ?? 'skip');
setRequirePlanApproval(feature.requirePlanApproval ?? false);
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
}
}, [feature]);
@@ -158,6 +167,8 @@ export function EditFeatureDialog({
imagePaths: editingFeature.imagePaths ?? [],
branchName: finalBranchName,
priority: editingFeature.priority ?? 2,
planningMode,
requirePlanApproval,
};
onUpdate(editingFeature.id, updates);
@@ -235,13 +246,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();
@@ -265,9 +276,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>
@@ -466,11 +477,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";
@@ -96,6 +97,8 @@ export function useBoardActions({
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => {
// Simplified: Only store branchName, no worktree creation on add
// Worktrees are created at execution time (when feature starts)
@@ -132,6 +135,8 @@ export function useBoardActions({
imagePaths: DescriptionImagePath[];
branchName: string;
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
) => {
const finalBranchName =

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

@@ -35,7 +35,7 @@ export function BranchSelector({
<Label htmlFor={`${testIdPrefix}-selector`}>Target Branch</Label>
<RadioGroup
value={useCurrentBranch ? "current" : "other"}
onValueChange={(value) => onUseCurrentBranchChange(value === "current")}
onValueChange={(value: string) => onUseCurrentBranchChange(value === "current")}
disabled={disabled}
data-testid={`${testIdPrefix}-radio-group`}
>

View File

@@ -5,3 +5,4 @@ export * from "./profile-quick-select";
export * from "./testing-tab-content";
export * from "./priority-selector";
export * from "./branch-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

@@ -6,7 +6,7 @@ import { toast } from "sonner";
import type { WorktreeInfo } from "../types";
interface UseWorktreeActionsOptions {
fetchWorktrees: () => Promise<void>;
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}

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