From 5ab53afd7f72eadc16fa1ba9ac89413615782928 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Wed, 21 Jan 2026 12:45:14 +0100 Subject: [PATCH] feat: add per-project default model override for new features (#640) * feat: add per-project default model override for new features - Add defaultFeatureModel to ProjectSettings type for project-level override - Add defaultFeatureModel to Project interface for UI state - Display Default Feature Model in Model Defaults section alongside phase models - Include Default Feature Model in global Bulk Replace dialog - Add Default Feature Model override section to Project Settings - Add setProjectDefaultFeatureModel store action for project-level overrides - Update clearAllProjectPhaseModelOverrides to also clear defaultFeatureModel - Update add-feature-dialog to use project override when available - Include Default Feature Model in Project Bulk Replace dialog This allows projects with different complexity levels to use different default models (e.g., Haiku for simple tasks, Opus for complex projects). * fix: add server-side __CLEAR__ handler for defaultFeatureModel - Add handler in settings-service.ts to properly delete defaultFeatureModel when '__CLEAR__' marker is sent from the UI - Fix bulk-replace-dialog.tsx to correctly return claude-opus when resetting default feature model to Anthropic Direct (was incorrectly using enhancementModel's settings which default to sonnet) These fixes ensure: 1. Clearing project default model override properly removes the setting instead of storing literal '__CLEAR__' string 2. Global bulk replace correctly resets default feature model to opus * fix: include defaultFeatureModel in Reset to Defaults action - Updated resetPhaseModels to also reset defaultFeatureModel to claude-opus - Fixed initial state to use canonical 'claude-opus' instead of 'opus' * refactor: use DEFAULT_GLOBAL_SETTINGS constant for defaultFeatureModel Address PR review feedback: - Replace hardcoded { model: 'claude-opus' } with DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel - Fix Prettier formatting for long destructuring lines - Import DEFAULT_GLOBAL_SETTINGS from @automaker/types where needed This improves maintainability by centralizing the default value. --- apps/server/src/services/settings-service.ts | 10 ++ .../board-view/dialogs/add-feature-dialog.tsx | 18 +- .../project-bulk-replace-dialog.tsx | 156 ++++++++++++------ .../project-models-section.tsx | 134 ++++++++++++++- .../model-defaults/bulk-replace-dialog.tsx | 147 +++++++++++------ .../model-defaults/model-defaults-section.tsx | 57 ++++++- apps/ui/src/lib/electron.ts | 5 + apps/ui/src/store/app-store.ts | 67 +++++++- libs/types/src/settings.ts | 7 + 9 files changed, 482 insertions(+), 119 deletions(-) diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 8c760c70..7f9b54e4 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -827,6 +827,16 @@ export class SettingsService { delete updated.phaseModelOverrides; } + // Handle defaultFeatureModel special cases: + // - "__CLEAR__" marker means delete the key (use global setting) + // - object means project-specific override + if ( + 'defaultFeatureModel' in updates && + (updates as Record).defaultFeatureModel === '__CLEAR__' + ) { + delete updated.defaultFeatureModel; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index e4ba03d4..77373e6c 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -195,8 +195,16 @@ export function AddFeatureDialog({ const [childDependencies, setChildDependencies] = useState([]); // Get defaults from store - const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } = - useAppStore(); + const { + defaultPlanningMode, + defaultRequirePlanApproval, + useWorktrees, + defaultFeatureModel, + currentProject, + } = useAppStore(); + + // Use project-level default feature model if set, otherwise fall back to global + const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel; // Track previous open state to detect when dialog opens const wasOpenRef = useRef(false); @@ -216,7 +224,7 @@ export function AddFeatureDialog({ ); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); - setModelEntry(defaultFeatureModel); + setModelEntry(effectiveDefaultFeatureModel); // Initialize description history (empty for new feature) setDescriptionHistory([]); @@ -241,7 +249,7 @@ export function AddFeatureDialog({ defaultBranch, defaultPlanningMode, defaultRequirePlanApproval, - defaultFeatureModel, + effectiveDefaultFeatureModel, useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode, @@ -343,7 +351,7 @@ export function AddFeatureDialog({ // When a non-main worktree is selected, use its branch name for custom mode setBranchName(selectedNonMainWorktreeBranch || ''); setPriority(2); - setModelEntry(defaultFeatureModel); + setModelEntry(effectiveDefaultFeatureModel); setWorkMode( getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode) ); diff --git a/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx index 66e2cb0e..c6209d5e 100644 --- a/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx @@ -25,7 +25,7 @@ import type { ClaudeCompatibleProvider, ClaudeModelAlias, } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types'; interface ProjectBulkReplaceDialogProps { open: boolean; @@ -50,6 +50,10 @@ const PHASE_LABELS: Record = { const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[]; +// Special key for default feature model (not a phase but included in bulk replace) +const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const; +type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY; + // Claude model display names const CLAUDE_MODEL_DISPLAY: Record = { haiku: 'Claude Haiku', @@ -62,11 +66,18 @@ export function ProjectBulkReplaceDialog({ onOpenChange, project, }: ProjectBulkReplaceDialogProps) { - const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore(); + const { + phaseModels, + setProjectPhaseModelOverride, + claudeCompatibleProviders, + defaultFeatureModel, + setProjectDefaultFeatureModel, + } = useAppStore(); const [selectedProvider, setSelectedProvider] = useState('anthropic'); // Get project-level overrides const projectOverrides = project.phaseModelOverrides || {}; + const projectDefaultFeatureModel = project.defaultFeatureModel; // Get enabled providers const enabledProviders = useMemo(() => { @@ -122,11 +133,15 @@ export function ProjectBulkReplaceDialog({ const findModelForClaudeAlias = ( provider: ClaudeCompatibleProvider | null, claudeAlias: ClaudeModelAlias, - phase: PhaseModelKey + key: ExtendedPhaseKey ): PhaseModelEntry => { if (!provider) { // Anthropic Direct - reset to default phase model (includes correct thinking levels) - return DEFAULT_PHASE_MODELS[phase]; + // For default feature model, use the default from global settings + if (key === DEFAULT_FEATURE_MODEL_KEY) { + return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + } + return DEFAULT_PHASE_MODELS[key]; } // Find model that maps to this Claude alias @@ -146,60 +161,91 @@ export function ProjectBulkReplaceDialog({ return { model: claudeAlias }; }; + // Helper to generate preview item for any entry + const generatePreviewItem = ( + key: ExtendedPhaseKey, + label: string, + currentEntry: PhaseModelEntry + ) => { + const claudeAlias = getClaudeModelAlias(currentEntry); + const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key); + + // Get display names + const getCurrentDisplay = (): string => { + if (currentEntry.providerId) { + const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === currentEntry.model); + return model?.displayName || currentEntry.model; + } + } + return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; + }; + + const getNewDisplay = (): string => { + if (newEntry.providerId && selectedProviderConfig) { + const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); + return model?.displayName || newEntry.model; + } + return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; + }; + + const isChanged = + currentEntry.model !== newEntry.model || + currentEntry.providerId !== newEntry.providerId || + currentEntry.thinkingLevel !== newEntry.thinkingLevel; + + return { + key, + label, + claudeAlias, + currentDisplay: getCurrentDisplay(), + newDisplay: getNewDisplay(), + newEntry, + isChanged, + }; + }; + // Generate preview of changes const preview = useMemo(() => { - return ALL_PHASES.map((phase) => { - // Current effective value (project override or global) + // Default feature model entry (first in the list) + const globalDefaultFeature = defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + const currentDefaultFeature = projectDefaultFeatureModel || globalDefaultFeature; + const defaultFeaturePreview = generatePreviewItem( + DEFAULT_FEATURE_MODEL_KEY, + 'Default Feature Model', + currentDefaultFeature + ); + + // Phase model entries + const phasePreview = ALL_PHASES.map((phase) => { const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]; const currentEntry = projectOverrides[phase] || globalEntry; - const claudeAlias = getClaudeModelAlias(currentEntry); - const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase); - - // Get display names - const getCurrentDisplay = (): string => { - if (currentEntry.providerId) { - const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); - if (provider) { - const model = provider.models?.find((m) => m.id === currentEntry.model); - return model?.displayName || currentEntry.model; - } - } - return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; - }; - - const getNewDisplay = (): string => { - if (newEntry.providerId && selectedProviderConfig) { - const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); - return model?.displayName || newEntry.model; - } - return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; - }; - - const isChanged = - currentEntry.model !== newEntry.model || - currentEntry.providerId !== newEntry.providerId || - currentEntry.thinkingLevel !== newEntry.thinkingLevel; - - return { - phase, - label: PHASE_LABELS[phase], - claudeAlias, - currentDisplay: getCurrentDisplay(), - newDisplay: getNewDisplay(), - newEntry, - isChanged, - }; + return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry); }); - }, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]); + + return [defaultFeaturePreview, ...phasePreview]; + }, [ + phaseModels, + projectOverrides, + selectedProviderConfig, + enabledProviders, + defaultFeatureModel, + projectDefaultFeatureModel, + ]); // Count how many will change const changeCount = preview.filter((p) => p.isChanged).length; // Apply the bulk replace as project overrides const handleApply = () => { - preview.forEach(({ phase, newEntry, isChanged }) => { + preview.forEach(({ key, newEntry, isChanged }) => { if (isChanged) { - setProjectPhaseModelOverride(project.id, phase, newEntry); + if (key === DEFAULT_FEATURE_MODEL_KEY) { + setProjectDefaultFeatureModel(project.id, newEntry); + } else { + setProjectPhaseModelOverride(project.id, key as PhaseModelKey, newEntry); + } } }); onOpenChange(false); @@ -295,7 +341,7 @@ export function ProjectBulkReplaceDialog({
- {changeCount} of {ALL_PHASES.length} will be overridden + {changeCount} of {preview.length} will be overridden
@@ -311,15 +357,23 @@ export function ProjectBulkReplaceDialog({ - {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + {preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => ( - {label} + + {label} + {key === DEFAULT_FEATURE_MODEL_KEY && ( + + Feature Default + + )} + {currentDisplay} {isChanged ? ( diff --git a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx index 809439c1..e0e1f1ba 100644 --- a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; -import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react'; +import { Workflow, RotateCcw, Globe, Check, Replace, Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Project } from '@/lib/electron'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog'; import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types'; interface ProjectModelsSectionProps { project: Project; @@ -88,6 +88,127 @@ const MEMORY_TASKS: PhaseConfig[] = [ const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS]; +/** + * Default feature model override section for per-project settings. + */ +function FeatureDefaultModelOverrideSection({ project }: { project: Project }) { + const { + defaultFeatureModel: globalDefaultFeatureModel, + setProjectDefaultFeatureModel, + claudeCompatibleProviders, + } = useAppStore(); + + const globalValue: PhaseModelEntry = + globalDefaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + const projectOverride = project.defaultFeatureModel; + const hasOverride = !!projectOverride; + const effectiveValue = projectOverride || globalValue; + + // Get display name for a model + const getModelDisplayName = (entry: PhaseModelEntry): string => { + if (entry.providerId) { + const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === entry.model); + if (model) { + return `${model.displayName} (${provider.name})`; + } + } + } + // Default to model ID for built-in models (both short aliases and canonical IDs) + const modelMap: Record = { + haiku: 'Claude Haiku', + sonnet: 'Claude Sonnet', + opus: 'Claude Opus', + 'claude-haiku': 'Claude Haiku', + 'claude-sonnet': 'Claude Sonnet', + 'claude-opus': 'Claude Opus', + }; + return modelMap[entry.model] || entry.model; + }; + + const handleClearOverride = () => { + setProjectDefaultFeatureModel(project.id, null); + }; + + const handleSetOverride = (entry: PhaseModelEntry) => { + setProjectDefaultFeatureModel(project.id, entry); + }; + + return ( +
+
+

Feature Defaults

+

+ Default model for new feature cards in this project +

+
+
+
+
+
+
+ +
+

Default Feature Model

+ {hasOverride ? ( + + Override + + ) : ( + + + Global + + )} +
+

+ Model and thinking level used when creating new feature cards +

+ {hasOverride && ( +

+ Using: {getModelDisplayName(effectiveValue)} +

+ )} + {!hasOverride && ( +

+ Using global: {getModelDisplayName(globalValue)} +

+ )} +
+ +
+ {hasOverride && ( + + )} + +
+
+
+
+ ); +} + function PhaseOverrideItem({ phase, project, @@ -234,8 +355,10 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); - // Count how many overrides are set - const overrideCount = Object.keys(project.phaseModelOverrides || {}).length; + // Count how many overrides are set (including defaultFeatureModel) + const phaseOverrideCount = Object.keys(project.phaseModelOverrides || {}).length; + const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel; + const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0); // Check if Claude is available const isClaudeDisabled = disabledProviders.includes('claude'); @@ -328,6 +451,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { {/* Content */}
+ {/* Feature Defaults */} + + {/* Quick Tasks */} = { const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[]; +// Special key for default feature model (not a phase but included in bulk replace) +const DEFAULT_FEATURE_MODEL_KEY = '__defaultFeatureModel__' as const; +type ExtendedPhaseKey = PhaseModelKey | typeof DEFAULT_FEATURE_MODEL_KEY; + // Claude model display names const CLAUDE_MODEL_DISPLAY: Record = { haiku: 'Claude Haiku', @@ -56,7 +60,13 @@ const CLAUDE_MODEL_DISPLAY: Record = { }; export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) { - const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore(); + const { + phaseModels, + setPhaseModel, + claudeCompatibleProviders, + defaultFeatureModel, + setDefaultFeatureModel, + } = useAppStore(); const [selectedProvider, setSelectedProvider] = useState('anthropic'); // Get enabled providers @@ -113,11 +123,15 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps const findModelForClaudeAlias = ( provider: ClaudeCompatibleProvider | null, claudeAlias: ClaudeModelAlias, - phase: PhaseModelKey + key: ExtendedPhaseKey ): PhaseModelEntry => { if (!provider) { // Anthropic Direct - reset to default phase model (includes correct thinking levels) - return DEFAULT_PHASE_MODELS[phase]; + // For default feature model, use the default from global settings + if (key === DEFAULT_FEATURE_MODEL_KEY) { + return DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + } + return DEFAULT_PHASE_MODELS[key]; } // Find model that maps to this Claude alias @@ -137,58 +151,83 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps return { model: claudeAlias }; }; + // Helper to generate preview item for any entry + const generatePreviewItem = ( + key: ExtendedPhaseKey, + label: string, + currentEntry: PhaseModelEntry + ) => { + const claudeAlias = getClaudeModelAlias(currentEntry); + const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, key); + + // Get display names + const getCurrentDisplay = (): string => { + if (currentEntry.providerId) { + const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); + if (provider) { + const model = provider.models?.find((m) => m.id === currentEntry.model); + return model?.displayName || currentEntry.model; + } + } + return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; + }; + + const getNewDisplay = (): string => { + if (newEntry.providerId && selectedProviderConfig) { + const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); + return model?.displayName || newEntry.model; + } + return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; + }; + + const isChanged = + currentEntry.model !== newEntry.model || + currentEntry.providerId !== newEntry.providerId || + currentEntry.thinkingLevel !== newEntry.thinkingLevel; + + return { + key, + label, + claudeAlias, + currentDisplay: getCurrentDisplay(), + newDisplay: getNewDisplay(), + newEntry, + isChanged, + }; + }; + // Generate preview of changes const preview = useMemo(() => { - return ALL_PHASES.map((phase) => { + // Default feature model entry (first in the list) + const defaultFeatureModelEntry = + defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + const defaultFeaturePreview = generatePreviewItem( + DEFAULT_FEATURE_MODEL_KEY, + 'Default Feature Model', + defaultFeatureModelEntry + ); + + // Phase model entries + const phasePreview = ALL_PHASES.map((phase) => { const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase]; - const claudeAlias = getClaudeModelAlias(currentEntry); - const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase); - - // Get display names - const getCurrentDisplay = (): string => { - if (currentEntry.providerId) { - const provider = enabledProviders.find((p) => p.id === currentEntry.providerId); - if (provider) { - const model = provider.models?.find((m) => m.id === currentEntry.model); - return model?.displayName || currentEntry.model; - } - } - return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model; - }; - - const getNewDisplay = (): string => { - if (newEntry.providerId && selectedProviderConfig) { - const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model); - return model?.displayName || newEntry.model; - } - return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model; - }; - - const isChanged = - currentEntry.model !== newEntry.model || - currentEntry.providerId !== newEntry.providerId || - currentEntry.thinkingLevel !== newEntry.thinkingLevel; - - return { - phase, - label: PHASE_LABELS[phase], - claudeAlias, - currentDisplay: getCurrentDisplay(), - newDisplay: getNewDisplay(), - newEntry, - isChanged, - }; + return generatePreviewItem(phase, PHASE_LABELS[phase], currentEntry); }); - }, [phaseModels, selectedProviderConfig, enabledProviders]); + + return [defaultFeaturePreview, ...phasePreview]; + }, [phaseModels, selectedProviderConfig, enabledProviders, defaultFeatureModel]); // Count how many will change const changeCount = preview.filter((p) => p.isChanged).length; // Apply the bulk replace const handleApply = () => { - preview.forEach(({ phase, newEntry, isChanged }) => { + preview.forEach(({ key, newEntry, isChanged }) => { if (isChanged) { - setPhaseModel(phase, newEntry); + if (key === DEFAULT_FEATURE_MODEL_KEY) { + setDefaultFeatureModel(newEntry); + } else { + setPhaseModel(key as PhaseModelKey, newEntry); + } } }); onOpenChange(false); @@ -284,7 +323,7 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
- {changeCount} of {ALL_PHASES.length} will change + {changeCount} of {preview.length} will change
@@ -298,15 +337,23 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps - {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + {preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => ( - {label} + + {label} + {key === DEFAULT_FEATURE_MODEL_KEY && ( + + Feature Default + + )} + {currentDisplay} {isChanged ? ( diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx index e12000fb..2fb4c9d3 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx @@ -1,12 +1,12 @@ import { useState } from 'react'; -import { Workflow, RotateCcw, Replace } from 'lucide-react'; +import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { PhaseModelSelector } from './phase-model-selector'; import { BulkReplaceDialog } from './bulk-replace-dialog'; -import type { PhaseModelKey } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types'; +import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types'; interface PhaseConfig { key: PhaseModelKey; @@ -113,6 +113,54 @@ function PhaseGroup({ ); } +/** + * Default model for new feature cards section. + * This is separate from phase models but logically belongs with model configuration. + */ +function FeatureDefaultModelSection() { + const { defaultFeatureModel, setDefaultFeatureModel } = useAppStore(); + const defaultValue: PhaseModelEntry = + defaultFeatureModel ?? DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel; + + return ( +
+
+

Feature Defaults

+

+ Default model for new feature cards when created +

+
+
+
+
+
+ +
+
+

Default Feature Model

+

+ Model and thinking level used when creating new feature cards +

+
+
+ +
+
+
+ ); +} + export function ModelDefaultsSection() { const { resetPhaseModels, claudeCompatibleProviders } = useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); @@ -171,6 +219,9 @@ export function ModelDefaultsSection() { {/* Content */}
+ {/* Feature Defaults */} + + {/* Quick Tasks */} ; + /** + * Override the default model for new feature cards in this project. + * If not specified, falls back to the global defaultFeatureModel setting. + */ + defaultFeatureModel?: import('@automaker/types').PhaseModelEntry; } export interface TrashedProject extends Project { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 63dd7960..e78cd80f 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -42,6 +42,7 @@ import { DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, DEFAULT_MAX_CONCURRENCY, + DEFAULT_GLOBAL_SETTINGS, } from '@automaker/types'; const logger = createLogger('AppStore'); @@ -1055,6 +1056,12 @@ export interface AppActions { ) => void; clearAllProjectPhaseModelOverrides: (projectId: string) => void; + // Project Default Feature Model Override + setProjectDefaultFeatureModel: ( + projectId: string, + entry: import('@automaker/types').PhaseModelEntry | null // null = use global + ) => void; + // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; @@ -1527,7 +1534,7 @@ const initialState: AppState = { specCreatingForProject: null, defaultPlanningMode: 'skip' as PlanningMode, defaultRequirePlanApproval: false, - defaultFeatureModel: { model: 'opus' } as PhaseModelEntry, + defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, pendingPlanApproval: null, claudeRefreshInterval: 60, claudeUsage: null, @@ -2105,9 +2112,11 @@ export const useAppStore = create()((set, get) => ({ return; } - // Clear overrides from project + // Clear all model overrides from project (phaseModelOverrides + defaultFeatureModel) const projects = get().projects.map((p) => - p.id === projectId ? { ...p, phaseModelOverrides: undefined } : p + p.id === projectId + ? { ...p, phaseModelOverrides: undefined, defaultFeatureModel: undefined } + : p ); set({ projects }); @@ -2118,6 +2127,49 @@ export const useAppStore = create()((set, get) => ({ currentProject: { ...currentProject, phaseModelOverrides: undefined, + defaultFeatureModel: undefined, + }, + }); + } + + // Persist to server (clear both) + const httpClient = getHttpApiClient(); + httpClient.settings + .updateProject(project.path, { + phaseModelOverrides: '__CLEAR__', + defaultFeatureModel: '__CLEAR__', + }) + .catch((error) => { + console.error('Failed to clear model overrides:', error); + }); + }, + + setProjectDefaultFeatureModel: (projectId, entry) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot set default feature model: project not found'); + return; + } + + // Update the project's defaultFeatureModel + const projects = get().projects.map((p) => + p.id === projectId + ? { + ...p, + defaultFeatureModel: entry ?? undefined, + } + : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + defaultFeatureModel: entry ?? undefined, }, }); } @@ -2126,10 +2178,10 @@ export const useAppStore = create()((set, get) => ({ const httpClient = getHttpApiClient(); httpClient.settings .updateProject(project.path, { - phaseModelOverrides: '__CLEAR__', + defaultFeatureModel: entry ?? '__CLEAR__', }) .catch((error) => { - console.error('Failed to clear phaseModelOverrides:', error); + console.error('Failed to persist defaultFeatureModel:', error); }); }, @@ -2571,7 +2623,10 @@ export const useAppStore = create()((set, get) => ({ await syncSettingsToServer(); }, resetPhaseModels: async () => { - set({ phaseModels: DEFAULT_PHASE_MODELS }); + set({ + phaseModels: DEFAULT_PHASE_MODELS, + defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, + }); // Sync to server settings file const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 8a10a6f8..f6401314 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1186,6 +1186,13 @@ export interface ProjectSettings { */ phaseModelOverrides?: Partial; + // Feature Defaults Override (per-project) + /** + * Override the default model for new feature cards in this project. + * If not specified, falls back to the global defaultFeatureModel setting. + */ + defaultFeatureModel?: PhaseModelEntry; + // Deprecated Claude API Profile Override /** * @deprecated Use phaseModelOverrides instead.