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.
This commit is contained in:
Stefan de Vogelaere
2026-01-21 12:45:14 +01:00
committed by GitHub
parent 3ebd67f35f
commit 5ab53afd7f
9 changed files with 482 additions and 119 deletions

View File

@@ -24,7 +24,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 BulkReplaceDialogProps {
open: boolean;
@@ -48,6 +48,10 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
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<ClaudeModelAlias, string> = {
haiku: 'Claude Haiku',
@@ -56,7 +60,13 @@ const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
};
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
const {
phaseModels,
setPhaseModel,
claudeCompatibleProviders,
defaultFeatureModel,
setDefaultFeatureModel,
} = useAppStore();
const [selectedProvider, setSelectedProvider] = useState<string>('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
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Preview Changes</label>
<span className="text-xs text-muted-foreground">
{changeCount} of {ALL_PHASES.length} will change
{changeCount} of {preview.length} will change
</span>
</div>
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
@@ -298,15 +337,23 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
</tr>
</thead>
<tbody>
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
{preview.map(({ key, label, currentDisplay, newDisplay, isChanged }) => (
<tr
key={phase}
key={key}
className={cn(
'border-t border-border/50',
isChanged ? 'bg-brand-500/5' : 'opacity-50'
isChanged ? 'bg-brand-500/5' : 'opacity-50',
key === DEFAULT_FEATURE_MODEL_KEY && 'bg-accent/30'
)}
>
<td className="p-2 font-medium">{label}</td>
<td className="p-2 font-medium">
{label}
{key === DEFAULT_FEATURE_MODEL_KEY && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-500">
Feature Default
</span>
)}
</td>
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
<td className="p-2 text-center">
{isChanged ? (

View File

@@ -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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-foreground">Feature Defaults</h3>
<p className="text-xs text-muted-foreground">
Default model for new feature cards when created
</p>
</div>
<div className="space-y-3">
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
<div className="flex items-center gap-3 flex-1 pr-4">
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-brand-500" />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
<p className="text-xs text-muted-foreground">
Model and thinking level used when creating new feature cards
</p>
</div>
</div>
<PhaseModelSelector
compact
value={defaultValue}
onChange={setDefaultFeatureModel}
align="end"
/>
</div>
</div>
</div>
);
}
export function ModelDefaultsSection() {
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
const [showBulkReplace, setShowBulkReplace] = useState(false);
@@ -171,6 +219,9 @@ export function ModelDefaultsSection() {
{/* Content */}
<div className="p-6 space-y-8">
{/* Feature Defaults */}
<FeatureDefaultModelSection />
{/* Quick Tasks */}
<PhaseGroup
title="Quick Tasks"