Merge pull request #590 from AutoMaker-Org/automode-api

feat: implement cursor model migration and enhance auto mode function…
This commit is contained in:
Web Dev Cody
2026-01-18 18:59:59 -05:00
committed by GitHub
36 changed files with 1508 additions and 592 deletions

View File

@@ -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';
}

View File

@@ -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);

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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',
});

View File

@@ -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));

View File

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

View File

@@ -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');
}
};

View File

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

View File

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