mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: implement plan approval functionality in board view
- Introduced PlanApprovalDialog for reviewing and approving feature plans. - Added state management for pending plan approvals and loading states. - Enhanced BoardView to handle plan approval actions, including approve and reject functionalities. - Updated KanbanCard and KanbanBoard components to include buttons for viewing and approving plans. - Integrated plan approval logic into the auto mode service, allowing for user feedback and plan edits. - Updated app state to manage default plan approval settings and integrate with existing feature workflows.
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
FeatureSuggestionsDialog,
|
FeatureSuggestionsDialog,
|
||||||
FollowUpDialog,
|
FollowUpDialog,
|
||||||
|
PlanApprovalDialog,
|
||||||
} from "./board-view/dialogs";
|
} from "./board-view/dialogs";
|
||||||
import { COLUMNS } from "./board-view/constants";
|
import { COLUMNS } from "./board-view/constants";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,9 @@ export function BoardView() {
|
|||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
|
pendingPlanApproval,
|
||||||
|
setPendingPlanApproval,
|
||||||
|
updateFeature,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
@@ -80,6 +84,8 @@ export function BoardView() {
|
|||||||
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
const [showCompletedModal, setShowCompletedModal] = useState(false);
|
||||||
const [deleteCompletedFeature, setDeleteCompletedFeature] =
|
const [deleteCompletedFeature, setDeleteCompletedFeature] =
|
||||||
useState<Feature | null>(null);
|
useState<Feature | null>(null);
|
||||||
|
// State for viewing plan in read-only mode
|
||||||
|
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
|
||||||
|
|
||||||
// Follow-up state hook
|
// Follow-up state hook
|
||||||
const {
|
const {
|
||||||
@@ -111,6 +117,8 @@ export function BoardView() {
|
|||||||
} = useSuggestionsState();
|
} = useSuggestionsState();
|
||||||
// Search filter for Kanban cards
|
// Search filter for Kanban cards
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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
|
// Derive spec creation state from store - check if current project is the one being created
|
||||||
const isCreatingSpec = specCreatingForProject === currentProject?.path;
|
const isCreatingSpec = specCreatingForProject === currentProject?.path;
|
||||||
const creatingSpecProjectPath = specCreatingForProject ?? undefined;
|
const creatingSpecProjectPath = specCreatingForProject ?? undefined;
|
||||||
@@ -297,6 +305,130 @@ export function BoardView() {
|
|||||||
currentProject,
|
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) {
|
if (!currentProject) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -387,6 +519,8 @@ export function BoardView() {
|
|||||||
onMerge={handleMergeFeature}
|
onMerge={handleMergeFeature}
|
||||||
onComplete={handleCompleteFeature}
|
onComplete={handleCompleteFeature}
|
||||||
onImplement={handleStartImplementation}
|
onImplement={handleStartImplementation}
|
||||||
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
featuresWithContext={featuresWithContext}
|
featuresWithContext={featuresWithContext}
|
||||||
runningAutoTasks={runningAutoTasks}
|
runningAutoTasks={runningAutoTasks}
|
||||||
shortcuts={shortcuts}
|
shortcuts={shortcuts}
|
||||||
@@ -494,6 +628,34 @@ export function BoardView() {
|
|||||||
isGenerating={isGeneratingSuggestions}
|
isGenerating={isGeneratingSuggestions}
|
||||||
setIsGenerating={setIsGeneratingSuggestions}
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ interface KanbanCardProps {
|
|||||||
onMerge?: () => void;
|
onMerge?: () => void;
|
||||||
onImplement?: () => void;
|
onImplement?: () => void;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
|
onViewPlan?: () => void;
|
||||||
|
onApprovePlan?: () => void;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
@@ -134,6 +136,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onMerge,
|
onMerge,
|
||||||
onImplement,
|
onImplement,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
onViewPlan,
|
||||||
|
onApprovePlan,
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -858,14 +862,31 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{isCurrentAutoTask && (
|
{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 && (
|
{onViewOutput && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
|
className="flex-1 min-w-0 h-7 text-[11px] bg-[var(--status-info)] hover:bg-[var(--status-info)]/90"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onViewOutput();
|
onViewOutput();
|
||||||
@@ -873,11 +894,11 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
data-testid={`view-output-${feature.id}`}
|
data-testid={`view-output-${feature.id}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
Logs
|
<span className="truncate">Logs</span>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<span
|
<span
|
||||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20"
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-white/20 shrink-0"
|
||||||
data-testid={`shortcut-key-${feature.id}`}
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
>
|
>
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
@@ -889,7 +910,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-[11px] px-2"
|
className="h-7 text-[11px] px-2 shrink-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onForceStop();
|
onForceStop();
|
||||||
@@ -904,6 +925,23 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
{!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 ? (
|
{feature.skipTests && onManualVerify ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -1099,6 +1137,22 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Edit className="w-3 h-3 mr-1" />
|
<Edit className="w-3 h-3 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</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 && (
|
{onImplement && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ interface AddFeatureDialogProps {
|
|||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
@@ -96,9 +97,10 @@ export function AddFeatureDialog({
|
|||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
|
|
||||||
// Get enhancement model and default planning mode from store
|
// Get enhancement model and default planning mode from store
|
||||||
const { enhancementModel, defaultPlanningMode } = useAppStore();
|
const { enhancementModel, defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
||||||
|
|
||||||
// Sync defaults when dialog opens
|
// Sync defaults when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,8 +110,9 @@ export function AddFeatureDialog({
|
|||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
}));
|
}));
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
}
|
}
|
||||||
}, [open, defaultSkipTests, defaultPlanningMode]);
|
}, [open, defaultSkipTests, defaultPlanningMode, defaultRequirePlanApproval]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!newFeature.description.trim()) {
|
if (!newFeature.description.trim()) {
|
||||||
@@ -134,6 +137,7 @@ export function AddFeatureDialog({
|
|||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
priority: newFeature.priority,
|
priority: newFeature.priority,
|
||||||
planningMode,
|
planningMode,
|
||||||
|
requirePlanApproval,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@@ -149,6 +153,7 @@ export function AddFeatureDialog({
|
|||||||
thinkingLevel: "none",
|
thinkingLevel: "none",
|
||||||
});
|
});
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setNewFeaturePreviewMap(new Map());
|
setNewFeaturePreviewMap(new Map());
|
||||||
setShowAdvancedOptions(false);
|
setShowAdvancedOptions(false);
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
@@ -408,6 +413,8 @@ export function AddFeatureDialog({
|
|||||||
<PlanningModeSelector
|
<PlanningModeSelector
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
onModeChange={setPlanningMode}
|
onModeChange={setPlanningMode}
|
||||||
|
requireApproval={requirePlanApproval}
|
||||||
|
onRequireApprovalChange={setRequirePlanApproval}
|
||||||
featureDescription={newFeature.description}
|
featureDescription={newFeature.description}
|
||||||
testIdPrefix="add-feature"
|
testIdPrefix="add-feature"
|
||||||
compact
|
compact
|
||||||
|
|||||||
@@ -187,6 +187,36 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
newContent = prepContent;
|
newContent = prepContent;
|
||||||
break;
|
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 "auto_mode_feature_complete":
|
case "auto_mode_feature_complete":
|
||||||
const emoji = event.passes ? "✅" : "⚠️";
|
const emoji = event.passes ? "✅" : "⚠️";
|
||||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface EditFeatureDialogProps {
|
|||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
@@ -89,6 +90,7 @@ export function EditFeatureDialog({
|
|||||||
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
const [enhancementMode, setEnhancementMode] = useState<'improve' | 'technical' | 'simplify' | 'acceptance'>('improve');
|
||||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||||
|
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
|
||||||
|
|
||||||
// Get enhancement model from store
|
// Get enhancement model from store
|
||||||
const { enhancementModel } = useAppStore();
|
const { enhancementModel } = useAppStore();
|
||||||
@@ -97,6 +99,7 @@ export function EditFeatureDialog({
|
|||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
setPlanningMode(feature.planningMode ?? 'skip');
|
setPlanningMode(feature.planningMode ?? 'skip');
|
||||||
|
setRequirePlanApproval(feature.requirePlanApproval ?? false);
|
||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setShowEditAdvancedOptions(false);
|
setShowEditAdvancedOptions(false);
|
||||||
@@ -121,6 +124,7 @@ export function EditFeatureDialog({
|
|||||||
imagePaths: editingFeature.imagePaths ?? [],
|
imagePaths: editingFeature.imagePaths ?? [],
|
||||||
priority: editingFeature.priority ?? 2,
|
priority: editingFeature.priority ?? 2,
|
||||||
planningMode,
|
planningMode,
|
||||||
|
requirePlanApproval,
|
||||||
};
|
};
|
||||||
|
|
||||||
onUpdate(editingFeature.id, updates);
|
onUpdate(editingFeature.id, updates);
|
||||||
@@ -394,6 +398,8 @@ export function EditFeatureDialog({
|
|||||||
<PlanningModeSelector
|
<PlanningModeSelector
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
onModeChange={setPlanningMode}
|
onModeChange={setPlanningMode}
|
||||||
|
requireApproval={requirePlanApproval}
|
||||||
|
onRequireApprovalChange={setRequirePlanApproval}
|
||||||
featureDescription={editingFeature.description}
|
featureDescription={editingFeature.description}
|
||||||
testIdPrefix="edit-feature"
|
testIdPrefix="edit-feature"
|
||||||
compact
|
compact
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog"
|
|||||||
export { EditFeatureDialog } from "./edit-feature-dialog";
|
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||||
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||||
export { FollowUpDialog } from "./follow-up-dialog";
|
export { FollowUpDialog } from "./follow-up-dialog";
|
||||||
|
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"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, X, 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>
|
||||||
|
|
||||||
|
{/* Reject Feedback Section - Only show when not in viewOnly mode */}
|
||||||
|
{showRejectFeedback && !viewOnly && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label htmlFor="reject-feedback">Feedback (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reject-feedback"
|
||||||
|
value={rejectFeedback}
|
||||||
|
onChange={(e) => setRejectFeedback(e.target.value)}
|
||||||
|
placeholder="Provide feedback on why this plan is being rejected..."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Confirm Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Reject
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store";
|
import { Feature, FeatureImage, AgentModel, ThinkingLevel, PlanningMode, useAppStore } from "@/store/app-store";
|
||||||
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -67,6 +67,8 @@ export function useBoardActions({
|
|||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
planningMode: PlanningMode;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
@@ -91,6 +93,8 @@ export function useBoardActions({
|
|||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
imagePaths: DescriptionImagePath[];
|
imagePaths: DescriptionImagePath[];
|
||||||
priority: number;
|
priority: number;
|
||||||
|
planningMode?: PlanningMode;
|
||||||
|
requirePlanApproval?: boolean;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
updateFeature(featureId, updates);
|
updateFeature(featureId, updates);
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
.play()
|
.play()
|
||||||
.catch((err) => console.warn("Could not play ding sound:", err));
|
.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") {
|
} else if (event.type === "auto_mode_error") {
|
||||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ interface KanbanBoardProps {
|
|||||||
onMerge: (feature: Feature) => void;
|
onMerge: (feature: Feature) => void;
|
||||||
onComplete: (feature: Feature) => void;
|
onComplete: (feature: Feature) => void;
|
||||||
onImplement: (feature: Feature) => void;
|
onImplement: (feature: Feature) => void;
|
||||||
|
onViewPlan: (feature: Feature) => void;
|
||||||
|
onApprovePlan: (feature: Feature) => void;
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
shortcuts: ReturnType<typeof useKeyboardShortcutsConfig>;
|
||||||
@@ -81,6 +83,8 @@ export function KanbanBoard({
|
|||||||
onMerge,
|
onMerge,
|
||||||
onComplete,
|
onComplete,
|
||||||
onImplement,
|
onImplement,
|
||||||
|
onViewPlan,
|
||||||
|
onApprovePlan,
|
||||||
featuresWithContext,
|
featuresWithContext,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
@@ -195,6 +199,8 @@ export function KanbanBoard({
|
|||||||
onMerge={() => onMerge(feature)}
|
onMerge={() => onMerge(feature)}
|
||||||
onComplete={() => onComplete(feature)}
|
onComplete={() => onComplete(feature)}
|
||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(
|
isCurrentAutoTask={runningAutoTasks.includes(
|
||||||
feature.id
|
feature.id
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||||
@@ -25,6 +26,8 @@ export interface PlanSpec {
|
|||||||
interface PlanningModeSelectorProps {
|
interface PlanningModeSelectorProps {
|
||||||
mode: PlanningMode;
|
mode: PlanningMode;
|
||||||
onModeChange: (mode: PlanningMode) => void;
|
onModeChange: (mode: PlanningMode) => void;
|
||||||
|
requireApproval?: boolean;
|
||||||
|
onRequireApprovalChange?: (require: boolean) => void;
|
||||||
planSpec?: PlanSpec;
|
planSpec?: PlanSpec;
|
||||||
onGenerateSpec?: () => void;
|
onGenerateSpec?: () => void;
|
||||||
onApproveSpec?: () => void;
|
onApproveSpec?: () => void;
|
||||||
@@ -81,6 +84,8 @@ const modes = [
|
|||||||
export function PlanningModeSelector({
|
export function PlanningModeSelector({
|
||||||
mode,
|
mode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
|
requireApproval,
|
||||||
|
onRequireApprovalChange,
|
||||||
planSpec,
|
planSpec,
|
||||||
onGenerateSpec,
|
onGenerateSpec,
|
||||||
onApproveSpec,
|
onApproveSpec,
|
||||||
@@ -195,6 +200,24 @@ export function PlanningModeSelector({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 */}
|
{/* Spec Preview/Actions Panel - Only for spec/full modes */}
|
||||||
{requiresApproval && (
|
{requiresApproval && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export function SettingsView() {
|
|||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
setDefaultPlanningMode,
|
setDefaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
|
setDefaultRequirePlanApproval,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Convert electron Project to settings-view Project type
|
// Convert electron Project to settings-view Project type
|
||||||
@@ -122,10 +124,12 @@ export function SettingsView() {
|
|||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
onUseWorktreesChange={setUseWorktrees}
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "danger":
|
case "danger":
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
FlaskConical, Settings2, TestTube, GitBranch,
|
FlaskConical, Settings2, TestTube, GitBranch,
|
||||||
Zap, ClipboardList, FileText, ScrollText
|
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -20,10 +20,12 @@ interface FeatureDefaultsSectionProps {
|
|||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
|
defaultRequirePlanApproval: boolean;
|
||||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
@@ -31,10 +33,12 @@ export function FeatureDefaultsSection({
|
|||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval,
|
||||||
onShowProfilesOnlyChange,
|
onShowProfilesOnlyChange,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onUseWorktreesChange,
|
onUseWorktreesChange,
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
|
onDefaultRequirePlanApprovalChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -126,8 +130,40 @@ export function FeatureDefaultsSection({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Separator */}
|
||||||
<div className="border-t border-border/30" />
|
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
|
||||||
|
|
||||||
{/* Profiles Only Setting */}
|
{/* 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">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useAppStore } from "@/store/app-store";
|
|||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import type { AutoModeEvent } from "@/types/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)
|
* Hook for managing auto mode (scoped per project)
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +23,7 @@ export function useAutoMode() {
|
|||||||
addAutoModeActivity,
|
addAutoModeActivity,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
projects,
|
projects,
|
||||||
|
setPendingPlanApproval,
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
autoModeByProject: state.autoModeByProject,
|
autoModeByProject: state.autoModeByProject,
|
||||||
@@ -29,6 +35,7 @@ export function useAutoMode() {
|
|||||||
addAutoModeActivity: state.addAutoModeActivity,
|
addAutoModeActivity: state.addAutoModeActivity,
|
||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
|
setPendingPlanApproval: state.setPendingPlanApproval,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,8 +154,26 @@ export function useAutoMode() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "auto_mode_error":
|
case "auto_mode_error":
|
||||||
console.error("[AutoMode Error]", event.error);
|
|
||||||
if (event.featureId && event.error) {
|
if (event.featureId && event.error) {
|
||||||
|
// Check if this is a user-initiated cancellation (not a real error)
|
||||||
|
const isCancellation =
|
||||||
|
event.error.includes("cancelled") ||
|
||||||
|
event.error.includes("stopped") ||
|
||||||
|
event.error.includes("aborted");
|
||||||
|
|
||||||
|
if (isCancellation) {
|
||||||
|
// User cancelled the feature - just log as info, not an error
|
||||||
|
console.log("[AutoMode] Feature cancelled:", 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
|
// Check for authentication errors and provide a more helpful message
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
event.errorType === "authentication" ||
|
event.errorType === "authentication" ||
|
||||||
@@ -210,6 +235,64 @@ export function useAutoMode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +305,8 @@ export function useAutoMode() {
|
|||||||
setAutoModeRunning,
|
setAutoModeRunning,
|
||||||
addAutoModeActivity,
|
addAutoModeActivity,
|
||||||
getProjectIdFromPath,
|
getProjectIdFromPath,
|
||||||
|
setPendingPlanApproval,
|
||||||
|
currentProject?.path,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Restore auto mode for all projects that were running when app was closed
|
// Restore auto mode for all projects that were running when app was closed
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ export interface AutoModeAPI {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string
|
featureId: string
|
||||||
) => Promise<{ success: boolean; error?: 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;
|
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,6 +1429,23 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
|||||||
return { success: true };
|
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) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
mockAutoModeCallbacks.push(callback);
|
mockAutoModeCallbacks.push(callback);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -544,6 +544,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}),
|
}),
|
||||||
commitFeature: (projectPath: string, featureId: string) =>
|
commitFeature: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
||||||
|
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) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
return this.subscribeToEvent(
|
return this.subscribeToEvent(
|
||||||
"auto-mode:event",
|
"auto-mode:event",
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ export interface Feature {
|
|||||||
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
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
|
planningMode?: PlanningMode; // Planning mode for this feature
|
||||||
planSpec?: PlanSpec; // Generated spec/plan data
|
planSpec?: PlanSpec; // Generated spec/plan data
|
||||||
|
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlanSpec status for feature planning/specification
|
// PlanSpec status for feature planning/specification
|
||||||
@@ -461,6 +462,16 @@ export interface AppState {
|
|||||||
specCreatingForProject: string | null;
|
specCreatingForProject: string | null;
|
||||||
|
|
||||||
defaultPlanningMode: PlanningMode;
|
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
|
// Default background settings for board backgrounds
|
||||||
@@ -671,6 +682,15 @@ export interface AppActions {
|
|||||||
isSpecCreatingForProject: (projectPath: string) => boolean;
|
isSpecCreatingForProject: (projectPath: string) => boolean;
|
||||||
|
|
||||||
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
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
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -758,6 +778,8 @@ const initialState: AppState = {
|
|||||||
},
|
},
|
||||||
specCreatingForProject: null,
|
specCreatingForProject: null,
|
||||||
defaultPlanningMode: 'skip' as PlanningMode,
|
defaultPlanningMode: 'skip' as PlanningMode,
|
||||||
|
defaultRequirePlanApproval: false,
|
||||||
|
pendingPlanApproval: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()(
|
export const useAppStore = create<AppState & AppActions>()(
|
||||||
@@ -2138,6 +2160,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
||||||
|
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
||||||
|
|
||||||
|
// Plan Approval actions
|
||||||
|
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
@@ -2205,6 +2231,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Board background settings
|
// Board background settings
|
||||||
boardBackgroundByProject: state.boardBackgroundByProject,
|
boardBackgroundByProject: state.boardBackgroundByProject,
|
||||||
defaultPlanningMode: state.defaultPlanningMode,
|
defaultPlanningMode: state.defaultPlanningMode,
|
||||||
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
43
apps/app/src/types/electron.d.ts
vendored
43
apps/app/src/types/electron.d.ts
vendored
@@ -237,6 +237,38 @@ export type AutoModeEvent =
|
|||||||
recommendations: string[];
|
recommendations: string[];
|
||||||
estimatedCost?: number;
|
estimatedCost?: number;
|
||||||
estimatedTime?: string;
|
estimatedTime?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "plan_approval_required";
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
planContent: string;
|
||||||
|
planningMode: "lite" | "spec" | "full";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "plan_auto_approved";
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
planContent: string;
|
||||||
|
planningMode: "lite" | "spec" | "full";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "plan_approved";
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
hasEdits: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "plan_rejected";
|
||||||
|
featureId: string;
|
||||||
|
projectPath?: string;
|
||||||
|
feedback?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "planning_started";
|
||||||
|
featureId: string;
|
||||||
|
mode: "lite" | "spec" | "full";
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpecRegenerationEvent =
|
export type SpecRegenerationEvent =
|
||||||
@@ -398,6 +430,17 @@ export interface AutoModeAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
approvePlan: (
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
approved: boolean,
|
||||||
|
editedPlan?: string,
|
||||||
|
feedback?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
onEvent: (callback: (event: AutoModeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createContextExistsHandler } from "./routes/context-exists.js";
|
|||||||
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
|
import { createAnalyzeProjectHandler } from "./routes/analyze-project.js";
|
||||||
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
|
import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js";
|
||||||
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
|
import { createCommitFeatureHandler } from "./routes/commit-feature.js";
|
||||||
|
import { createApprovePlanHandler } from "./routes/approve-plan.js";
|
||||||
|
|
||||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -35,6 +36,7 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
|||||||
createFollowUpFeatureHandler(autoModeService)
|
createFollowUpFeatureHandler(autoModeService)
|
||||||
);
|
);
|
||||||
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
|
router.post("/commit-feature", createCommitFeatureHandler(autoModeService));
|
||||||
|
router.post("/approve-plan", createApprovePlanHandler(autoModeService));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal file
78
apps/server/src/routes/auto-mode/routes/approve-plan.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,7 +47,25 @@ Before implementing, create a brief planning outline:
|
|||||||
4. **Tasks**: Numbered task list (3-7 items)
|
4. **Tasks**: Numbered task list (3-7 items)
|
||||||
5. **Risks**: Any gotchas to watch for
|
5. **Risks**: Any gotchas to watch for
|
||||||
|
|
||||||
Present this outline, then proceed with implementation.`,
|
After generating the outline, output:
|
||||||
|
"[PLAN_GENERATED] Planning outline complete."
|
||||||
|
|
||||||
|
Then proceed with implementation.`,
|
||||||
|
|
||||||
|
lite_with_approval: `## Planning Phase (Lite Mode)
|
||||||
|
|
||||||
|
Before implementing, create a brief planning outline:
|
||||||
|
|
||||||
|
1. **Goal**: What are we accomplishing? (1 sentence)
|
||||||
|
2. **Approach**: How will we do it? (2-3 sentences)
|
||||||
|
3. **Files to Touch**: List files and what changes
|
||||||
|
4. **Tasks**: Numbered task list (3-7 items)
|
||||||
|
5. **Risks**: Any gotchas to watch for
|
||||||
|
|
||||||
|
After generating the outline, output:
|
||||||
|
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
|
||||||
|
|
||||||
|
DO NOT proceed with implementation until you receive explicit approval.`,
|
||||||
|
|
||||||
spec: `## Specification Phase (Spec Mode)
|
spec: `## Specification Phase (Spec Mode)
|
||||||
|
|
||||||
@@ -110,6 +128,7 @@ interface Feature {
|
|||||||
>;
|
>;
|
||||||
planningMode?: PlanningMode;
|
planningMode?: PlanningMode;
|
||||||
planSpec?: PlanSpec;
|
planSpec?: PlanSpec;
|
||||||
|
requirePlanApproval?: boolean; // If true, pause for user approval before implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunningFeature {
|
interface RunningFeature {
|
||||||
@@ -128,12 +147,20 @@ interface AutoModeConfig {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingApproval {
|
||||||
|
resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
featureId: string;
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AutoModeService {
|
export class AutoModeService {
|
||||||
private events: EventEmitter;
|
private events: EventEmitter;
|
||||||
private runningFeatures = new Map<string, RunningFeature>();
|
private runningFeatures = new Map<string, RunningFeature>();
|
||||||
private autoLoopRunning = false;
|
private autoLoopRunning = false;
|
||||||
private autoLoopAbortController: AbortController | null = null;
|
private autoLoopAbortController: AbortController | null = null;
|
||||||
private config: AutoModeConfig | null = null;
|
private config: AutoModeConfig | null = null;
|
||||||
|
private pendingApprovals = new Map<string, PendingApproval>();
|
||||||
|
|
||||||
constructor(events: EventEmitter) {
|
constructor(events: EventEmitter) {
|
||||||
this.events = events;
|
this.events = events;
|
||||||
@@ -252,7 +279,8 @@ export class AutoModeService {
|
|||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees = true,
|
useWorktrees = true,
|
||||||
isAutoMode = false
|
isAutoMode = false,
|
||||||
|
customPrompt?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.runningFeatures.has(featureId)) {
|
if (this.runningFeatures.has(featureId)) {
|
||||||
throw new Error(`Feature ${featureId} is already running`);
|
throw new Error(`Feature ${featureId} is already running`);
|
||||||
@@ -304,10 +332,15 @@ export class AutoModeService {
|
|||||||
// Update feature status to in_progress
|
// Update feature status to in_progress
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
await this.updateFeatureStatus(projectPath, featureId, "in_progress");
|
||||||
|
|
||||||
// Build the prompt with planning phase if needed
|
// Use custom prompt if provided, otherwise build the prompt with planning phase
|
||||||
const featurePrompt = this.buildFeaturePrompt(feature);
|
let prompt: string;
|
||||||
const planningPrefix = this.getPlanningPromptPrefix(feature);
|
if (customPrompt) {
|
||||||
const prompt = planningPrefix + featurePrompt;
|
prompt = customPrompt;
|
||||||
|
} else {
|
||||||
|
const featurePrompt = this.buildFeaturePrompt(feature);
|
||||||
|
const planningPrefix = this.getPlanningPromptPrefix(feature);
|
||||||
|
prompt = planningPrefix + featurePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit planning mode info
|
// Emit planning mode info
|
||||||
if (feature.planningMode && feature.planningMode !== 'skip') {
|
if (feature.planningMode && feature.planningMode !== 'skip') {
|
||||||
@@ -336,7 +369,12 @@ export class AutoModeService {
|
|||||||
prompt,
|
prompt,
|
||||||
abortController,
|
abortController,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
model
|
model,
|
||||||
|
{
|
||||||
|
projectPath,
|
||||||
|
planningMode: feature.planningMode,
|
||||||
|
requirePlanApproval: feature.requirePlanApproval,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as waiting_approval for user review
|
// Mark as waiting_approval for user review
|
||||||
@@ -375,6 +413,8 @@ export class AutoModeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
|
||||||
|
console.log(`[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
|
||||||
this.runningFeatures.delete(featureId);
|
this.runningFeatures.delete(featureId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,6 +428,9 @@ export class AutoModeService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any pending plan approval for this feature
|
||||||
|
this.cancelPlanApproval(featureId);
|
||||||
|
|
||||||
running.abortController.abort();
|
running.abortController.abort();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -614,13 +657,18 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use fullPrompt (already built above) with model and all images
|
// Use fullPrompt (already built above) with model and all images
|
||||||
|
// Note: Follow-ups skip planning mode - they continue from previous work
|
||||||
await this.runAgent(
|
await this.runAgent(
|
||||||
workDir,
|
workDir,
|
||||||
featureId,
|
featureId,
|
||||||
fullPrompt,
|
fullPrompt,
|
||||||
abortController,
|
abortController,
|
||||||
allImagePaths.length > 0 ? allImagePaths : imagePaths,
|
allImagePaths.length > 0 ? allImagePaths : imagePaths,
|
||||||
model
|
model,
|
||||||
|
{
|
||||||
|
projectPath,
|
||||||
|
planningMode: 'skip', // Follow-ups don't require approval
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as waiting_approval for user review
|
// Mark as waiting_approval for user review
|
||||||
@@ -925,6 +973,155 @@ Format your response as a structured markdown document.`;
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for plan approval from the user.
|
||||||
|
* Returns a promise that resolves when the user approves/rejects the plan.
|
||||||
|
*/
|
||||||
|
waitForPlanApproval(
|
||||||
|
featureId: string,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> {
|
||||||
|
console.log(`[AutoMode] Registering pending approval for feature ${featureId}`);
|
||||||
|
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pendingApprovals.set(featureId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
console.log(`[AutoMode] Pending approval registered for feature ${featureId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pending plan approval.
|
||||||
|
* Called when the user approves or rejects the plan via API.
|
||||||
|
*/
|
||||||
|
async resolvePlanApproval(
|
||||||
|
featureId: string,
|
||||||
|
approved: boolean,
|
||||||
|
editedPlan?: string,
|
||||||
|
feedback?: string,
|
||||||
|
projectPathFromClient?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
console.log(`[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
|
||||||
|
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
|
||||||
|
const pending = this.pendingApprovals.get(featureId);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
console.log(`[AutoMode] No pending approval in Map for feature ${featureId}`);
|
||||||
|
|
||||||
|
// RECOVERY: If no pending approval but we have projectPath from client,
|
||||||
|
// check if feature's planSpec.status is 'generated' and handle recovery
|
||||||
|
if (projectPathFromClient) {
|
||||||
|
console.log(`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`);
|
||||||
|
const feature = await this.loadFeature(projectPathFromClient, featureId);
|
||||||
|
|
||||||
|
if (feature?.planSpec?.status === 'generated') {
|
||||||
|
console.log(`[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery`);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
// Update planSpec to approved
|
||||||
|
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
||||||
|
status: 'approved',
|
||||||
|
approvedAt: new Date().toISOString(),
|
||||||
|
reviewedByUser: true,
|
||||||
|
content: editedPlan || feature.planSpec.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build continuation prompt and re-run the feature
|
||||||
|
const planContent = editedPlan || feature.planSpec.content || '';
|
||||||
|
let continuationPrompt = `The plan/specification has been approved. `;
|
||||||
|
if (feedback) {
|
||||||
|
continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`;
|
||||||
|
}
|
||||||
|
continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`;
|
||||||
|
|
||||||
|
console.log(`[AutoMode] Starting recovery execution for feature ${featureId}`);
|
||||||
|
|
||||||
|
// Start feature execution with the continuation prompt (async, don't await)
|
||||||
|
this.executeFeature(projectPathFromClient, featureId, true, false, continuationPrompt)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(`[AutoMode] Recovery execution failed for feature ${featureId}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
// Rejected - update status and emit event
|
||||||
|
await this.updateFeaturePlanSpec(projectPathFromClient, featureId, {
|
||||||
|
status: 'rejected',
|
||||||
|
reviewedByUser: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog');
|
||||||
|
|
||||||
|
this.emitAutoModeEvent('plan_rejected', {
|
||||||
|
featureId,
|
||||||
|
projectPath: projectPathFromClient,
|
||||||
|
feedback,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AutoMode] ERROR: No pending approval found for feature ${featureId} and recovery not possible`);
|
||||||
|
return { success: false, error: `No pending approval for feature ${featureId}` };
|
||||||
|
}
|
||||||
|
console.log(`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`);
|
||||||
|
|
||||||
|
const { projectPath } = pending;
|
||||||
|
|
||||||
|
// Update feature's planSpec status
|
||||||
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||||
|
status: approved ? 'approved' : 'rejected',
|
||||||
|
approvedAt: approved ? new Date().toISOString() : undefined,
|
||||||
|
reviewedByUser: true,
|
||||||
|
content: editedPlan, // Update content if user provided an edited version
|
||||||
|
});
|
||||||
|
|
||||||
|
// If rejected with feedback, we can store it for the user to see
|
||||||
|
if (!approved && feedback) {
|
||||||
|
// Emit event so client knows the rejection reason
|
||||||
|
this.emitAutoModeEvent('plan_rejected', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
feedback,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the promise with all data including feedback
|
||||||
|
pending.resolve({ approved, editedPlan, feedback });
|
||||||
|
this.pendingApprovals.delete(featureId);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a pending plan approval (e.g., when feature is stopped).
|
||||||
|
*/
|
||||||
|
cancelPlanApproval(featureId: string): void {
|
||||||
|
console.log(`[AutoMode] cancelPlanApproval called for feature ${featureId}`);
|
||||||
|
console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`);
|
||||||
|
const pending = this.pendingApprovals.get(featureId);
|
||||||
|
if (pending) {
|
||||||
|
console.log(`[AutoMode] Found and cancelling pending approval for feature ${featureId}`);
|
||||||
|
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
||||||
|
this.pendingApprovals.delete(featureId);
|
||||||
|
} else {
|
||||||
|
console.log(`[AutoMode] No pending approval to cancel for feature ${featureId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature has a pending plan approval.
|
||||||
|
*/
|
||||||
|
hasPendingApproval(featureId: string): boolean {
|
||||||
|
return this.pendingApprovals.has(featureId);
|
||||||
|
}
|
||||||
|
|
||||||
// Private helpers
|
// Private helpers
|
||||||
|
|
||||||
private async setupWorktree(
|
private async setupWorktree(
|
||||||
@@ -1018,6 +1215,50 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the planSpec of a feature
|
||||||
|
*/
|
||||||
|
private async updateFeaturePlanSpec(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
updates: Partial<PlanSpec>
|
||||||
|
): Promise<void> {
|
||||||
|
const featurePath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"feature.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(featurePath, "utf-8");
|
||||||
|
const feature = JSON.parse(data);
|
||||||
|
|
||||||
|
// Initialize planSpec if it doesn't exist
|
||||||
|
if (!feature.planSpec) {
|
||||||
|
feature.planSpec = {
|
||||||
|
status: 'pending',
|
||||||
|
version: 1,
|
||||||
|
reviewedByUser: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
Object.assign(feature.planSpec, updates);
|
||||||
|
|
||||||
|
// If content is being updated and it's a new version, increment version
|
||||||
|
if (updates.content && updates.content !== feature.planSpec.content) {
|
||||||
|
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
feature.updatedAt = new Date().toISOString();
|
||||||
|
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
|
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
|
||||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||||
|
|
||||||
@@ -1083,7 +1324,13 @@ Format your response as a structured markdown document.`;
|
|||||||
return ''; // No planning phase
|
return ''; // No planning phase
|
||||||
}
|
}
|
||||||
|
|
||||||
const planningPrompt = PLANNING_PROMPTS[mode];
|
// For lite mode, use the approval variant if requirePlanApproval is true
|
||||||
|
let promptKey: string = mode;
|
||||||
|
if (mode === 'lite' && feature.requirePlanApproval === true) {
|
||||||
|
promptKey = 'lite_with_approval';
|
||||||
|
}
|
||||||
|
|
||||||
|
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
|
||||||
if (!planningPrompt) {
|
if (!planningPrompt) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -1091,31 +1338,6 @@ Format your response as a structured markdown document.`;
|
|||||||
return planningPrompt + '\n\n---\n\n## Feature Request\n\n';
|
return planningPrompt + '\n\n---\n\n## Feature Request\n\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract image paths from feature's imagePaths array
|
|
||||||
* Handles both string paths and objects with path property
|
|
||||||
*/
|
|
||||||
private extractImagePaths(
|
|
||||||
imagePaths:
|
|
||||||
| Array<string | { path: string; [key: string]: unknown }>
|
|
||||||
| undefined,
|
|
||||||
projectPath: string
|
|
||||||
): string[] {
|
|
||||||
if (!imagePaths || imagePaths.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return imagePaths
|
|
||||||
.map((imgPath) => {
|
|
||||||
const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path;
|
|
||||||
// Resolve relative paths to absolute paths
|
|
||||||
return path.isAbsolute(pathStr)
|
|
||||||
? pathStr
|
|
||||||
: path.join(projectPath, pathStr);
|
|
||||||
})
|
|
||||||
.filter((p) => p); // Filter out any empty paths
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFeaturePrompt(feature: Feature): string {
|
private buildFeaturePrompt(feature: Feature): string {
|
||||||
const title = this.extractTitleFromDescription(feature.description);
|
const title = this.extractTitleFromDescription(feature.description);
|
||||||
|
|
||||||
@@ -1181,8 +1403,24 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
model?: string
|
model?: string,
|
||||||
|
options?: {
|
||||||
|
projectPath?: string;
|
||||||
|
planningMode?: PlanningMode;
|
||||||
|
requirePlanApproval?: boolean;
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const projectPath = options?.projectPath || workDir;
|
||||||
|
const planningMode = options?.planningMode || 'skip';
|
||||||
|
// Check if this planning mode can generate a spec/plan that needs approval
|
||||||
|
// - spec and full always generate specs
|
||||||
|
// - lite only generates approval-ready content when requirePlanApproval is true
|
||||||
|
const planningModeRequiresApproval =
|
||||||
|
planningMode === 'spec' ||
|
||||||
|
planningMode === 'full' ||
|
||||||
|
(planningMode === 'lite' && options?.requirePlanApproval === true);
|
||||||
|
const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true;
|
||||||
|
|
||||||
// Build SDK options using centralized configuration for feature implementation
|
// Build SDK options using centralized configuration for feature implementation
|
||||||
const sdkOptions = createAutoModeOptions({
|
const sdkOptions = createAutoModeOptions({
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
@@ -1196,7 +1434,7 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`
|
`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get provider for this model
|
// Get provider for this model
|
||||||
@@ -1214,7 +1452,7 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
false // don't duplicate paths in text
|
false // don't duplicate paths in text
|
||||||
);
|
);
|
||||||
|
|
||||||
const options: ExecuteOptions = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
model: finalModel,
|
model: finalModel,
|
||||||
maxTurns: maxTurns,
|
maxTurns: maxTurns,
|
||||||
@@ -1224,8 +1462,9 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Execute via provider
|
// Execute via provider
|
||||||
const stream = provider.executeQuery(options);
|
const stream = provider.executeQuery(executeOptions);
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
|
let specDetected = false;
|
||||||
const outputPath = path.join(
|
const outputPath = path.join(
|
||||||
workDir,
|
workDir,
|
||||||
".automaker",
|
".automaker",
|
||||||
@@ -1234,7 +1473,7 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
"agent-output.md"
|
"agent-output.md"
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const msg of stream) {
|
streamLoop: for await (const msg of stream) {
|
||||||
if (msg.type === "assistant" && msg.message?.content) {
|
if (msg.type === "assistant" && msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
@@ -1253,10 +1492,157 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_progress", {
|
// Check for [SPEC_GENERATED] marker in planning modes (spec or full)
|
||||||
featureId,
|
if (planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]')) {
|
||||||
content: block.text,
|
specDetected = true;
|
||||||
});
|
|
||||||
|
// Extract plan content (everything before the marker)
|
||||||
|
const markerIndex = responseText.indexOf('[SPEC_GENERATED]');
|
||||||
|
const planContent = responseText.substring(0, markerIndex).trim();
|
||||||
|
|
||||||
|
// Update planSpec status to 'generated' and save content
|
||||||
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||||
|
status: 'generated',
|
||||||
|
content: planContent,
|
||||||
|
version: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
reviewedByUser: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let approvedPlanContent = planContent;
|
||||||
|
let userFeedback: string | undefined;
|
||||||
|
|
||||||
|
// Only pause for approval if requirePlanApproval is true
|
||||||
|
if (requiresApproval) {
|
||||||
|
console.log(`[AutoMode] Spec generated for feature ${featureId}, waiting for approval`);
|
||||||
|
|
||||||
|
// CRITICAL: Register pending approval BEFORE emitting event
|
||||||
|
// This prevents race condition where frontend tries to approve before
|
||||||
|
// the approval is registered in pendingApprovals Map
|
||||||
|
const approvalPromise = this.waitForPlanApproval(featureId, projectPath);
|
||||||
|
|
||||||
|
// NOW emit plan_approval_required event (approval is already registered)
|
||||||
|
this.emitAutoModeEvent('plan_approval_required', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
planContent,
|
||||||
|
planningMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for user approval
|
||||||
|
try {
|
||||||
|
const approvalResult = await approvalPromise;
|
||||||
|
|
||||||
|
if (!approvalResult.approved) {
|
||||||
|
// User rejected the plan - abort feature execution
|
||||||
|
console.log(`[AutoMode] Plan rejected for feature ${featureId}`);
|
||||||
|
throw new Error('Plan rejected by user');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AutoMode] Plan approved for feature ${featureId}, continuing with implementation`);
|
||||||
|
|
||||||
|
// If user provided an edited plan, update the planSpec content
|
||||||
|
if (approvalResult.editedPlan) {
|
||||||
|
approvedPlanContent = approvalResult.editedPlan;
|
||||||
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||||
|
content: approvalResult.editedPlan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture user feedback if provided
|
||||||
|
userFeedback = approvalResult.feedback;
|
||||||
|
|
||||||
|
// Emit event to notify implementation is starting
|
||||||
|
this.emitAutoModeEvent('plan_approved', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
hasEdits: !!approvalResult.editedPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes('cancelled')) {
|
||||||
|
throw error; // Re-throw cancellation errors
|
||||||
|
}
|
||||||
|
throw new Error(`Plan approval failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-approve: requirePlanApproval is false, just continue without pausing
|
||||||
|
console.log(`[AutoMode] Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)`);
|
||||||
|
|
||||||
|
// Emit info event for frontend
|
||||||
|
this.emitAutoModeEvent('plan_auto_approved', {
|
||||||
|
featureId,
|
||||||
|
projectPath,
|
||||||
|
planContent,
|
||||||
|
planningMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: After approval, we need to make a second call to continue implementation
|
||||||
|
// The agent is waiting for "approved" - we need to send it and continue
|
||||||
|
console.log(`[AutoMode] Making continuation call after plan approval for feature ${featureId}`);
|
||||||
|
|
||||||
|
// Update planSpec status to approved (handles both manual and auto-approval paths)
|
||||||
|
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||||
|
status: 'approved',
|
||||||
|
approvedAt: new Date().toISOString(),
|
||||||
|
reviewedByUser: requiresApproval,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build continuation prompt
|
||||||
|
let continuationPrompt = `The plan/specification has been approved. `;
|
||||||
|
if (userFeedback) {
|
||||||
|
continuationPrompt += `\n\nUser feedback: ${userFeedback}\n\n`;
|
||||||
|
}
|
||||||
|
continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${approvedPlanContent}\n\nImplement the feature now.`;
|
||||||
|
|
||||||
|
// Make continuation call
|
||||||
|
const continuationStream = provider.executeQuery({
|
||||||
|
prompt: continuationPrompt,
|
||||||
|
model: finalModel,
|
||||||
|
maxTurns: maxTurns,
|
||||||
|
cwd: workDir,
|
||||||
|
allowedTools: allowedTools,
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process continuation stream
|
||||||
|
for await (const contMsg of continuationStream) {
|
||||||
|
if (contMsg.type === "assistant" && contMsg.message?.content) {
|
||||||
|
for (const contBlock of contMsg.message.content) {
|
||||||
|
if (contBlock.type === "text") {
|
||||||
|
responseText += contBlock.text || "";
|
||||||
|
this.emitAutoModeEvent("auto_mode_progress", {
|
||||||
|
featureId,
|
||||||
|
content: contBlock.text,
|
||||||
|
});
|
||||||
|
} else if (contBlock.type === "tool_use") {
|
||||||
|
this.emitAutoModeEvent("auto_mode_tool", {
|
||||||
|
featureId,
|
||||||
|
tool: contBlock.name,
|
||||||
|
input: contBlock.input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (contMsg.type === "error") {
|
||||||
|
throw new Error(contMsg.error || "Unknown error during implementation");
|
||||||
|
} else if (contMsg.type === "result" && contMsg.subtype === "success") {
|
||||||
|
responseText += contMsg.result || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AutoMode] Implementation completed for feature ${featureId}`);
|
||||||
|
// Exit the original stream loop since continuation is done
|
||||||
|
break streamLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit progress for non-marker text (marker was already handled above)
|
||||||
|
if (!specDetected) {
|
||||||
|
this.emitAutoModeEvent("auto_mode_progress", {
|
||||||
|
featureId,
|
||||||
|
content: block.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (block.type === "tool_use") {
|
} else if (block.type === "tool_use") {
|
||||||
this.emitAutoModeEvent("auto_mode_tool", {
|
this.emitAutoModeEvent("auto_mode_tool", {
|
||||||
featureId,
|
featureId,
|
||||||
@@ -1305,7 +1691,7 @@ ${context}
|
|||||||
## Instructions
|
## Instructions
|
||||||
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
|
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
|
||||||
|
|
||||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
return this.executeFeature(projectPath, featureId, useWorktrees, false, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,26 @@ export interface Feature {
|
|||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||||
[key: string]: unknown;
|
status?: string;
|
||||||
|
model?: string;
|
||||||
|
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 {
|
export class FeatureLoader {
|
||||||
|
|||||||
Reference in New Issue
Block a user