feat: add customizable phase configuration in app settings

Allow users to customize the model and thinking level for each phase
(Spec Creation, Planning, Coding, QA Review) when using the Auto profile.
These settings are persisted in app settings and used as defaults when
creating new tasks.

Changes:
- Add customPhaseModels and customPhaseThinking to AppSettings type
- Update AgentProfileSettings to show editable phase configuration
  when Auto profile is selected
- Update TaskCreationWizard to initialize from custom settings
- Phase config can still be overridden per-task in task creation wizard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AndyMik90
2025-12-20 13:38:38 +01:00
parent 9981ee4469
commit aee0ba4cc5
4 changed files with 206 additions and 24 deletions
@@ -94,7 +94,7 @@ export function registerSettingsHandlers(
ipcMain.handle( ipcMain.handle(
IPC_CHANNELS.SETTINGS_GET, IPC_CHANNELS.SETTINGS_GET,
async (): Promise<IPCResult<AppSettings>> => { async (): Promise<IPCResult<AppSettings>> => {
let settings = { ...DEFAULT_APP_SETTINGS }; let settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
let needsSave = false; let needsSave = false;
if (existsSync(settingsPath)) { if (existsSync(settingsPath)) {
@@ -106,12 +106,15 @@ export function registerSettingsHandlers(
} }
} }
// Migration: Reset agent profile to 'auto' for existing users (one-time) // Migration: Set agent profile to 'auto' for users who haven't made a selection (one-time)
// This ensures all users get the optimized 'auto' profile as the default // This ensures new users get the optimized 'auto' profile as the default
const settingsAny = settings as Record<string, unknown>; // while preserving existing user preferences
if (!settingsAny._migratedAgentProfileToAuto) { if (!settings._migratedAgentProfileToAuto) {
settings.selectedAgentProfile = 'auto'; // Only set 'auto' if user hasn't made a selection yet
settingsAny._migratedAgentProfileToAuto = true; if (!settings.selectedAgentProfile) {
settings.selectedAgentProfile = 'auto';
}
settings._migratedAgentProfileToAuto = true;
needsSave = true; needsSave = true;
} }
@@ -127,8 +130,9 @@ export function registerSettingsHandlers(
if (needsSave) { if (needsSave) {
try { try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
} catch { } catch (error) {
// Ignore write errors during migration console.error('[SETTINGS_GET] Failed to persist migration:', error);
// Continue anyway - settings will be migrated in-memory for this session
} }
} }
@@ -102,11 +102,12 @@ export function TaskCreationWizard({
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model); const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel); const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
// Auto profile - per-phase configuration // Auto profile - per-phase configuration
// Use custom settings from app settings if available, otherwise fall back to defaults
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>( const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
); );
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>( const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
); );
// Image attachments // Image attachments
@@ -143,8 +144,8 @@ export function TaskCreationWizard({
setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto'); setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto');
setModel(draft.model || selectedProfile.model); setModel(draft.model || selectedProfile.model);
setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel); setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel);
setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); setPhaseModels(draft.phaseModels || settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setPhaseThinking(draft.phaseThinking || settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
setImages(draft.images); setImages(draft.images);
setReferencedFiles(draft.referencedFiles ?? []); setReferencedFiles(draft.referencedFiles ?? []);
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false); setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
@@ -159,15 +160,15 @@ export function TaskCreationWizard({
} }
// Note: Referenced Files section is always visible, no need to expand // Note: Referenced Files section is always visible, no need to expand
} else { } else {
// No draft - initialize from selected profile // No draft - initialize from selected profile and custom settings
setProfileId(settings.selectedAgentProfile || 'auto'); setProfileId(settings.selectedAgentProfile || 'auto');
setModel(selectedProfile.model); setModel(selectedProfile.model);
setThinkingLevel(selectedProfile.thinkingLevel); setThinkingLevel(selectedProfile.thinkingLevel);
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
} }
} }
}, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]); }, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel]);
// Fetch branches and project default branch when dialog opens // Fetch branches and project default branch when dialog opens
useEffect(() => { useEffect(() => {
@@ -542,12 +543,12 @@ export function TaskCreationWizard({
setPriority(''); setPriority('');
setComplexity(''); setComplexity('');
setImpact(''); setImpact('');
// Reset to selected profile defaults // Reset to selected profile defaults and custom settings
setProfileId(settings.selectedAgentProfile || 'auto'); setProfileId(settings.selectedAgentProfile || 'auto');
setModel(selectedProfile.model); setModel(selectedProfile.model);
setThinkingLevel(selectedProfile.thinkingLevel); setThinkingLevel(selectedProfile.thinkingLevel);
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
setImages([]); setImages([]);
setReferencedFiles([]); setReferencedFiles([]);
setRequireReviewBeforeCoding(false); setRequireReviewBeforeCoding(false);
@@ -1,9 +1,25 @@
import { Brain, Scale, Zap, Check, Sparkles } from 'lucide-react'; import { useState } from 'react';
import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { DEFAULT_AGENT_PROFILES, AVAILABLE_MODELS, THINKING_LEVELS } from '../../../shared/constants'; import {
DEFAULT_AGENT_PROFILES,
AVAILABLE_MODELS,
THINKING_LEVELS,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
} from '../../../shared/constants';
import { useSettingsStore, saveSettings } from '../../stores/settings-store'; import { useSettingsStore, saveSettings } from '../../stores/settings-store';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import type { AgentProfile } from '../../../shared/types/settings'; import { Label } from '../ui/label';
import { Button } from '../ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui/select';
import type { AgentProfile, PhaseModelConfig, PhaseThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types/settings';
/** /**
* Icon mapping for agent profile icons * Icon mapping for agent profile icons
@@ -15,6 +31,13 @@ const iconMap: Record<string, React.ElementType> = {
Sparkles Sparkles
}; };
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' },
planning: { label: 'Planning', description: 'Implementation planning and architecture' },
coding: { label: 'Coding', description: 'Actual code implementation' },
qa: { label: 'QA Review', description: 'Quality assurance and validation' }
};
/** /**
* Agent Profile Settings component * Agent Profile Settings component
* Displays preset agent profiles for quick model/thinking level configuration * Displays preset agent profiles for quick model/thinking level configuration
@@ -23,9 +46,40 @@ const iconMap: Record<string, React.ElementType> = {
export function AgentProfileSettings() { export function AgentProfileSettings() {
const settings = useSettingsStore((state) => state.settings); const settings = useSettingsStore((state) => state.settings);
const selectedProfileId = settings.selectedAgentProfile || 'auto'; const selectedProfileId = settings.selectedAgentProfile || 'auto';
const [showPhaseConfig, setShowPhaseConfig] = useState(selectedProfileId === 'auto');
// Get current phase config from settings or defaults
const currentPhaseModels: PhaseModelConfig = settings.customPhaseModels || DEFAULT_PHASE_MODELS;
const currentPhaseThinking: PhaseThinkingConfig = settings.customPhaseThinking || DEFAULT_PHASE_THINKING;
const handleSelectProfile = async (profileId: string) => { const handleSelectProfile = async (profileId: string) => {
await saveSettings({ selectedAgentProfile: profileId }); const success = await saveSettings({ selectedAgentProfile: profileId });
if (!success) {
// Log error for debugging - in future could show user toast notification
console.error('Failed to save agent profile selection');
return;
}
// Auto-expand phase config when Auto profile is selected
if (profileId === 'auto') {
setShowPhaseConfig(true);
}
};
const handlePhaseModelChange = async (phase: keyof PhaseModelConfig, value: ModelTypeShort) => {
const newPhaseModels = { ...currentPhaseModels, [phase]: value };
await saveSettings({ customPhaseModels: newPhaseModels });
};
const handlePhaseThinkingChange = async (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
const newPhaseThinking = { ...currentPhaseThinking, [phase]: value };
await saveSettings({ customPhaseThinking: newPhaseThinking });
};
const handleResetToDefaults = async () => {
await saveSettings({
customPhaseModels: DEFAULT_PHASE_MODELS,
customPhaseThinking: DEFAULT_PHASE_THINKING
});
}; };
/** /**
@@ -44,6 +98,18 @@ export function AgentProfileSettings() {
return level?.label || thinkingValue; return level?.label || thinkingValue;
}; };
/**
* Check if current config differs from defaults
*/
const hasCustomConfig = (): boolean => {
const phases: Array<keyof PhaseModelConfig> = ['spec', 'planning', 'coding', 'qa'];
return phases.some(
phase =>
currentPhaseModels[phase] !== DEFAULT_PHASE_MODELS[phase] ||
currentPhaseThinking[phase] !== DEFAULT_PHASE_THINKING[phase]
);
};
/** /**
* Render a single profile card * Render a single profile card
*/ */
@@ -126,6 +192,112 @@ export function AgentProfileSettings() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{DEFAULT_AGENT_PROFILES.map(renderProfileCard)} {DEFAULT_AGENT_PROFILES.map(renderProfileCard)}
</div> </div>
{/* Phase Configuration (only for Auto profile) */}
{selectedProfileId === 'auto' && (
<div className="mt-6 rounded-lg border border-border bg-card">
{/* Header - Collapsible */}
<button
type="button"
onClick={() => setShowPhaseConfig(!showPhaseConfig)}
className="flex w-full items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors rounded-t-lg"
>
<div>
<h4 className="font-medium text-sm text-foreground">Phase Configuration</h4>
<p className="text-xs text-muted-foreground mt-0.5">
Customize model and thinking level for each phase
</p>
</div>
{showPhaseConfig ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Phase Configuration Content */}
{showPhaseConfig && (
<div className="border-t border-border p-4 space-y-4">
{/* Reset button */}
{hasCustomConfig() && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleResetToDefaults}
className="text-xs h-7"
>
<RotateCcw className="h-3 w-3 mr-1.5" />
Reset to defaults
</Button>
</div>
)}
{/* Phase Configuration Grid */}
<div className="space-y-4">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
<div key={phase} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">
{PHASE_LABELS[phase].label}
</Label>
<span className="text-xs text-muted-foreground">
{PHASE_LABELS[phase].description}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Model Select */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Model</Label>
<Select
value={currentPhaseModels[phase]}
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelTypeShort)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Thinking Level Select */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Thinking Level</Label>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* Info note */}
<p className="text-[10px] text-muted-foreground mt-4 pt-3 border-t border-border">
These settings will be used as defaults when creating new tasks with the Auto profile.
You can override them per-task in the task creation wizard.
</p>
</div>
)}
</div>
)}
</div> </div>
</SettingsSection> </SettingsSection>
); );
@@ -82,10 +82,15 @@ export interface AppSettings {
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
// Selected agent profile for preset model/thinking configurations // Selected agent profile for preset model/thinking configurations
selectedAgentProfile?: string; selectedAgentProfile?: string;
// Custom phase configuration for Auto profile (overrides defaults)
customPhaseModels?: PhaseModelConfig;
customPhaseThinking?: PhaseThinkingConfig;
// Changelog preferences // Changelog preferences
changelogFormat?: ChangelogFormat; changelogFormat?: ChangelogFormat;
changelogAudience?: ChangelogAudience; changelogAudience?: ChangelogAudience;
changelogEmojiLevel?: ChangelogEmojiLevel; changelogEmojiLevel?: ChangelogEmojiLevel;
// Migration flags (internal use)
_migratedAgentProfileToAuto?: boolean;
} }
// Auto-Claude Source Environment Configuration (for auto-claude repo .env) // Auto-Claude Source Environment Configuration (for auto-claude repo .env)