mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge pull request #590 from AutoMaker-Org/automode-api
feat: implement cursor model migration and enhance auto mode function…
This commit is contained in:
@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
|
||||
// Cursor models - canonical format includes 'cursor-' prefix
|
||||
// Also support legacy IDs for backward compatibility
|
||||
if (
|
||||
modelStr.includes('cursor') ||
|
||||
modelStr === 'auto' ||
|
||||
modelStr === 'composer-1' ||
|
||||
modelStr === 'cursor-auto' ||
|
||||
modelStr === 'cursor-composer-1'
|
||||
) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AgentView() {
|
||||
return () => window.removeEventListener('resize', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -856,68 +856,9 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
);
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
useEffect(() => {
|
||||
autoModeRunningRef.current = autoMode.isRunning;
|
||||
}, [autoMode.isRunning]);
|
||||
|
||||
// Use a ref to track the latest features to avoid effect re-runs when features change
|
||||
const hookFeaturesRef = useRef(hookFeatures);
|
||||
useEffect(() => {
|
||||
hookFeaturesRef.current = hookFeatures;
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
|
||||
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||
useEffect(() => {
|
||||
runningAutoTasksRef.current = runningAutoTasks;
|
||||
}, [runningAutoTasks]);
|
||||
|
||||
// Keep latest start handler without retriggering the auto mode effect
|
||||
const handleStartImplementationRef = useRef(handleStartImplementation);
|
||||
useEffect(() => {
|
||||
handleStartImplementationRef.current = handleStartImplementation;
|
||||
}, [handleStartImplementation]);
|
||||
|
||||
// Track features that are pending (started but not yet confirmed running)
|
||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to auto mode events to remove features from pending when they start running
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_feature_start':
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_complete':
|
||||
case 'auto_mode_error':
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
// NOTE: Auto mode polling loop has been moved to the backend.
|
||||
// The frontend now just toggles the backend's auto loop via API calls.
|
||||
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
@@ -976,219 +917,6 @@ export function BoardView() {
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
autoMode.isRunning,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
|
||||
const checkAndStartFeatures = async () => {
|
||||
// Check if auto mode is still running and effect is still active
|
||||
// Use ref to get the latest value, not the closure value
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent executions
|
||||
if (isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
logger.debug(
|
||||
'[AutoMode] Skipping check - isActive:',
|
||||
isActive,
|
||||
'autoModeRunning:',
|
||||
autoModeRunningRef.current,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
logger.debug(
|
||||
'[AutoMode] Checking features - running:',
|
||||
currentRunning,
|
||||
'available slots:',
|
||||
availableSlots
|
||||
);
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency.
|
||||
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
|
||||
// so we fall back to "all backlog features" when none are visible in the current view.
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeaturesInView = currentFeatures.filter((f) => {
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only when viewing primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
return isViewingPrimary;
|
||||
}
|
||||
|
||||
if (currentWorktreeBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// Show features assigned to primary worktree's branch
|
||||
return currentProject.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const backlogFeatures =
|
||||
backlogFeaturesInView.length > 0
|
||||
? backlogFeaturesInView
|
||||
: currentFeatures.filter((f) => f.status === 'backlog');
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Features - total:',
|
||||
currentFeatures.length,
|
||||
'backlog in view:',
|
||||
backlogFeaturesInView.length,
|
||||
'backlog total:',
|
||||
backlogFeatures.length
|
||||
);
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
logger.debug(
|
||||
'[AutoMode] No backlog features found, statuses:',
|
||||
currentFeatures.map((f) => f.status).join(', ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
|
||||
// should NOT exclude blocked features in that mode.
|
||||
const eligibleFeatures =
|
||||
enableDependencyBlocking && !skipVerificationInAutoMode
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
if (blockingDeps.length > 0) {
|
||||
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
|
||||
}
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Eligible features after dep check:',
|
||||
eligibleFeatures.length,
|
||||
'dependency blocking enabled:',
|
||||
enableDependencyBlocking
|
||||
);
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
const startImplementation = handleStartImplementationRef.current;
|
||||
if (!startImplementation) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[AutoMode] Starting',
|
||||
featuresToStart.length,
|
||||
'features:',
|
||||
featuresToStart.map((f) => f.id).join(', ')
|
||||
);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName, assign it to the primary branch so it can run consistently
|
||||
// even when the user is viewing a non-primary worktree.
|
||||
if (!feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Final check before starting implementation
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the implementation - server will derive workDir from feature.branchName
|
||||
const started = await startImplementation(feature);
|
||||
|
||||
// If successfully started, track it as pending until we receive the start event
|
||||
if (started) {
|
||||
pendingFeaturesRef.current.add(feature.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
// Clear pending features when effect unmounts or dependencies change
|
||||
pendingFeaturesRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
autoMode.isRunning,
|
||||
currentProject,
|
||||
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
|
||||
// that would clear pendingFeaturesRef and cause concurrency issues
|
||||
maxConcurrency,
|
||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||
currentWorktreeBranch,
|
||||
currentWorktreePath,
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
useBoardKeyboardShortcuts({
|
||||
features: hookFeatures,
|
||||
@@ -1403,9 +1131,13 @@ export function BoardView() {
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to start:', error);
|
||||
});
|
||||
} else {
|
||||
autoMode.stop();
|
||||
autoMode.stop().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to stop:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { migrateModelId } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
@@ -107,9 +108,9 @@ export function EditFeatureDialog({
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
// Model selection state - migrate legacy model IDs to canonical format
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature?.model) || 'claude-opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
@@ -157,9 +158,9 @@ export function EditFeatureDialog({
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
// Reset model entry - migrate legacy model IDs
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature.model) || 'claude-opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MassEditDialog({
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -160,7 +160,7 @@ export function MassEditDialog({
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
@@ -17,23 +17,27 @@ export type ModelOption = {
|
||||
hasThinking?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude models with canonical prefixed IDs
|
||||
* UI displays short labels but stores full canonical IDs
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: 'haiku',
|
||||
id: 'claude-haiku', // Canonical prefixed ID
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
id: 'claude-sonnet', // Canonical prefixed ID
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
id: 'claude-opus', // Canonical prefixed ID
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
|
||||
/**
|
||||
* Cursor models derived from CURSOR_MODEL_MAP
|
||||
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
|
||||
* IDs already have 'cursor-' prefix in the canonical format
|
||||
*/
|
||||
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
|
||||
id, // Already prefixed in canonical format
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
provider: 'cursor' as ModelProvider,
|
||||
|
||||
@@ -70,22 +70,30 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as any);
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
|
||||
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
|
||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||
return (
|
||||
enabledCursorModels.includes(model.id as any) ||
|
||||
enabledCursorModels.includes(unprefixedId as any)
|
||||
);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
|
||||
onModelSelect(cursorDefaultModel);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -279,8 +279,8 @@ export function PhaseModelSelector({
|
||||
}, [codexModels]);
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
// With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as CursorModelId);
|
||||
});
|
||||
|
||||
@@ -300,6 +300,7 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
// With canonical IDs, direct comparison works
|
||||
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
|
||||
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
|
||||
|
||||
@@ -352,7 +353,7 @@ export function PhaseModelSelector({
|
||||
const seenGroups = new Set<string>();
|
||||
|
||||
availableCursorModels.forEach((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
const cursorId = model.id as CursorModelId;
|
||||
|
||||
// Check if this model is standalone
|
||||
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
|
||||
@@ -908,8 +909,8 @@ export function PhaseModelSelector({
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
const modelValue = stripProviderPrefix(model.id);
|
||||
const isSelected = selectedModel === modelValue;
|
||||
// With canonical IDs, store the full prefixed ID
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
@@ -917,7 +918,7 @@ export function PhaseModelSelector({
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: modelValue as CursorModelId });
|
||||
onChange({ model: model.id as CursorModelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
@@ -1458,7 +1459,7 @@ export function PhaseModelSelector({
|
||||
return favorites.map((model) => {
|
||||
// Check if this favorite is part of a grouped model
|
||||
if (model.provider === 'cursor') {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
const cursorId = model.id as CursorModelId;
|
||||
const group = getModelGroup(cursorId);
|
||||
if (group) {
|
||||
// Skip if we already rendered this group
|
||||
|
||||
@@ -92,7 +92,8 @@ export function CursorModelConfiguration({
|
||||
<div className="grid gap-3">
|
||||
{availableModels.map((model) => {
|
||||
const isEnabled = enabledCursorModels.includes(model.id);
|
||||
const isAuto = model.id === 'auto';
|
||||
// With canonical IDs, 'auto' becomes 'cursor-auto'
|
||||
const isAuto = model.id === 'cursor-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -94,21 +94,33 @@ export function useAutoMode() {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
|
||||
// This is intentionally session-scoped to avoid auto-running features after a full app restart.
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const session = readAutoModeSession();
|
||||
const desired = session[currentProject.path];
|
||||
if (typeof desired !== 'boolean') return;
|
||||
const syncWithBackend = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
if (desired !== isAutoModeRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(currentProject.id, desired);
|
||||
}
|
||||
const result = await api.autoMode.status(currentProject.path);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(currentProject.id, backendIsRunning);
|
||||
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects
|
||||
@@ -139,6 +151,22 @@ export function useAutoMode() {
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_started':
|
||||
// Backend started auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend started auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_stopped':
|
||||
// Backend stopped auto loop - update UI state
|
||||
logger.info('[AutoMode] Backend stopped auto loop for project');
|
||||
if (eventProjectId) {
|
||||
setAutoModeRunning(eventProjectId, false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_start':
|
||||
if (event.featureId) {
|
||||
addRunningTask(eventProjectId, event.featureId);
|
||||
@@ -374,35 +402,92 @@ export function useAutoMode() {
|
||||
addAutoModeActivity,
|
||||
getProjectIdFromPath,
|
||||
setPendingPlanApproval,
|
||||
setAutoModeRunning,
|
||||
currentProject?.path,
|
||||
]);
|
||||
|
||||
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
|
||||
const start = useCallback(() => {
|
||||
// Start auto mode - calls backend to start the auto loop
|
||||
const start = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.start) {
|
||||
throw new Error('Start auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
|
||||
// Call backend to start the auto loop
|
||||
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
logger.error('Failed to start auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to start auto mode');
|
||||
}
|
||||
|
||||
logger.debug(`[AutoMode] Started successfully`);
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
logger.error('Error starting auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning, maxConcurrency]);
|
||||
|
||||
// Stop auto mode - UI only, running tasks continue until natural completion
|
||||
const stop = useCallback(() => {
|
||||
// Stop auto mode - calls backend to stop the auto loop
|
||||
const stop = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
logger.error('No project selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
// NOTE: We intentionally do NOT clear running tasks here.
|
||||
// Stopping auto mode only turns off the toggle to prevent new features
|
||||
// from being picked up. Running tasks will complete naturally and be
|
||||
// removed via the auto_mode_feature_complete event.
|
||||
logger.info('Stopped - running tasks will continue');
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.stop) {
|
||||
throw new Error('Stop auto mode API not available');
|
||||
}
|
||||
|
||||
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
|
||||
|
||||
// Optimistically update UI state (backend will confirm via event)
|
||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
||||
setAutoModeRunning(currentProject.id, false);
|
||||
|
||||
// Call backend to stop the auto loop
|
||||
const result = await api.autoMode.stop(currentProject.path);
|
||||
|
||||
if (!result.success) {
|
||||
// Revert UI state on failure
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.error('Failed to stop auto mode:', result.error);
|
||||
throw new Error(result.error || 'Failed to stop auto mode');
|
||||
}
|
||||
|
||||
// NOTE: Running tasks will continue until natural completion.
|
||||
// The backend stops picking up new features but doesn't abort running ones.
|
||||
logger.info('Stopped - running tasks will continue');
|
||||
} catch (error) {
|
||||
// Revert UI state on error
|
||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
||||
setAutoModeRunning(currentProject.id, true);
|
||||
logger.error('Error stopping auto mode:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject, setAutoModeRunning]);
|
||||
|
||||
// Stop a specific feature
|
||||
|
||||
@@ -31,7 +31,11 @@ import { useSetupStore } from '@/store/setup-store';
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
@@ -566,6 +570,19 @@ export function useSettingsMigration(): MigrationState {
|
||||
*/
|
||||
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
const current = useAppStore.getState();
|
||||
|
||||
// Migrate Cursor models to canonical format
|
||||
// IMPORTANT: Always use ALL available Cursor models to ensure new models are visible
|
||||
// Users who had old settings with a subset of models should still see all available models
|
||||
const allCursorModels = getAllCursorModelIds();
|
||||
const migratedCursorDefault = migrateCursorModelIds([
|
||||
settings.cursorDefaultModel ?? current.cursorDefaultModel ?? 'cursor-auto',
|
||||
])[0];
|
||||
const validCursorModelIds = new Set(allCursorModels);
|
||||
const sanitizedCursorDefaultModel = validCursorModelIds.has(migratedCursorDefault)
|
||||
? migratedCursorDefault
|
||||
: ('cursor-auto' as CursorModelId);
|
||||
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const incomingEnabledOpencodeModels =
|
||||
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
|
||||
@@ -631,15 +648,17 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
useWorktrees: settings.useWorktrees ?? true,
|
||||
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||
defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
|
||||
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
|
||||
model: 'claude-opus',
|
||||
},
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
serverLogLevel: settings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: settings.enableRequestLogging ?? true,
|
||||
enhancementModel: settings.enhancementModel ?? 'sonnet',
|
||||
validationModel: settings.validationModel ?? 'opus',
|
||||
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
|
||||
validationModel: settings.validationModel ?? 'claude-opus',
|
||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
|
||||
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefaultModel,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
|
||||
@@ -22,7 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
|
||||
import {
|
||||
DEFAULT_OPENCODE_MODEL,
|
||||
getAllOpencodeModelIds,
|
||||
getAllCursorModelIds,
|
||||
migrateCursorModelIds,
|
||||
migrateOpencodeModelIds,
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
type OpencodeModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
@@ -501,17 +507,35 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
|
||||
const serverSettings = result.settings as unknown as GlobalSettings;
|
||||
const currentAppState = useAppStore.getState();
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const incomingEnabledOpencodeModels =
|
||||
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
|
||||
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
|
||||
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
|
||||
)
|
||||
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
|
||||
: DEFAULT_OPENCODE_MODEL;
|
||||
const sanitizedEnabledOpencodeModels = Array.from(
|
||||
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
|
||||
|
||||
// Cursor models - ALWAYS use ALL available models to ensure new models are visible
|
||||
const allCursorModels = getAllCursorModelIds();
|
||||
const validCursorModelIds = new Set(allCursorModels);
|
||||
|
||||
// Migrate Cursor default model
|
||||
const migratedCursorDefault = migrateCursorModelIds([
|
||||
serverSettings.cursorDefaultModel ?? 'cursor-auto',
|
||||
])[0];
|
||||
const sanitizedCursorDefault = validCursorModelIds.has(migratedCursorDefault)
|
||||
? migratedCursorDefault
|
||||
: ('cursor-auto' as CursorModelId);
|
||||
|
||||
// Migrate OpenCode models to canonical format
|
||||
const migratedOpencodeModels = migrateOpencodeModelIds(
|
||||
serverSettings.enabledOpencodeModels ?? []
|
||||
);
|
||||
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
|
||||
const sanitizedEnabledOpencodeModels = migratedOpencodeModels.filter((id) =>
|
||||
validOpencodeModelIds.has(id)
|
||||
);
|
||||
|
||||
// Migrate OpenCode default model
|
||||
const migratedOpencodeDefault = migrateOpencodeModelIds([
|
||||
serverSettings.opencodeDefaultModel ?? DEFAULT_OPENCODE_MODEL,
|
||||
])[0];
|
||||
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(migratedOpencodeDefault)
|
||||
? migratedOpencodeDefault
|
||||
: DEFAULT_OPENCODE_MODEL;
|
||||
|
||||
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
|
||||
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
|
||||
@@ -523,6 +547,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
(modelId) => !modelId.startsWith('amazon-bedrock/')
|
||||
);
|
||||
|
||||
// Migrate phase models to canonical format
|
||||
const migratedPhaseModels = serverSettings.phaseModels
|
||||
? {
|
||||
enhancementModel: migratePhaseModelEntry(serverSettings.phaseModels.enhancementModel),
|
||||
fileDescriptionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.fileDescriptionModel
|
||||
),
|
||||
imageDescriptionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.imageDescriptionModel
|
||||
),
|
||||
validationModel: migratePhaseModelEntry(serverSettings.phaseModels.validationModel),
|
||||
specGenerationModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.specGenerationModel
|
||||
),
|
||||
featureGenerationModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.featureGenerationModel
|
||||
),
|
||||
backlogPlanningModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.backlogPlanningModel
|
||||
),
|
||||
projectAnalysisModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.projectAnalysisModel
|
||||
),
|
||||
suggestionsModel: migratePhaseModelEntry(serverSettings.phaseModels.suggestionsModel),
|
||||
memoryExtractionModel: migratePhaseModelEntry(
|
||||
serverSettings.phaseModels.memoryExtractionModel
|
||||
),
|
||||
commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Save theme to localStorage for fallback when server settings aren't available
|
||||
if (serverSettings.theme) {
|
||||
setItem(THEME_STORAGE_KEY, serverSettings.theme);
|
||||
@@ -539,15 +594,17 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
useWorktrees: serverSettings.useWorktrees,
|
||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
|
||||
defaultFeatureModel: serverSettings.defaultFeatureModel
|
||||
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
|
||||
: { model: 'claude-opus' },
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
|
||||
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
validationModel: serverSettings.validationModel,
|
||||
phaseModels: serverSettings.phaseModels,
|
||||
enabledCursorModels: serverSettings.enabledCursorModels,
|
||||
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
||||
phaseModels: migratedPhaseModels ?? serverSettings.phaseModels,
|
||||
enabledCursorModels: allCursorModels, // Always use ALL cursor models
|
||||
cursorDefaultModel: sanitizedCursorDefault,
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
|
||||
@@ -495,10 +495,12 @@ export interface AutoModeAPI {
|
||||
status: (projectPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
isAutoLoopRunning?: boolean;
|
||||
currentFeatureId?: string | null;
|
||||
runningFeatures?: string[];
|
||||
runningProjects?: string[];
|
||||
runningCount?: number;
|
||||
maxConcurrency?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
runFeature: (
|
||||
@@ -3226,7 +3228,7 @@ function createMockGitHubAPI(): GitHubAPI {
|
||||
estimatedComplexity: 'moderate' as const,
|
||||
},
|
||||
projectPath,
|
||||
model: model || 'sonnet',
|
||||
model: model || 'claude-sonnet',
|
||||
})
|
||||
);
|
||||
}, 2000);
|
||||
|
||||
@@ -1393,12 +1393,12 @@ const initialState: AppState = {
|
||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||
serverLogLevel: 'info', // Default to info level for server logs
|
||||
enableRequestLogging: true, // Default to enabled for HTTP request logging
|
||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||
enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement
|
||||
validationModel: 'claude-opus', // Default to opus for GitHub issue validation
|
||||
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
|
||||
favoriteModels: [],
|
||||
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
|
||||
cursorDefaultModel: 'auto', // Default to auto selection
|
||||
cursorDefaultModel: 'cursor-auto', // Default to auto selection
|
||||
enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default
|
||||
codexDefaultModel: 'codex-gpt-5.2-codex', // Default to GPT-5.2-Codex
|
||||
codexAutoLoadAgents: false, // Default to disabled (user must opt-in)
|
||||
|
||||
Reference in New Issue
Block a user