refactor: remove AI profile functionality and related components

- Deleted the AI profile management feature, including all associated views, hooks, and types.
- Updated settings and navigation components to remove references to AI profiles.
- Adjusted local storage and settings synchronization logic to reflect the removal of AI profiles.
- Cleaned up tests and utility functions that were dependent on the AI profile feature.

These changes streamline the application by eliminating unused functionality, improving maintainability and reducing complexity.
This commit is contained in:
webdevcody
2026-01-09 19:21:30 -05:00
parent 3f2707404c
commit fc20dd5ad4
40 changed files with 38 additions and 3140 deletions

View File

@@ -22,7 +22,6 @@ import type {
Credentials,
ProjectSettings,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
BoardBackgroundSettings,
@@ -299,7 +298,6 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('trashedProjects');
ignoreEmptyArrayOverwrite('projectHistory');
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('aiProfiles');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
@@ -617,18 +615,15 @@ export class SettingsService {
: false,
useWorktrees:
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
muteDoneSound: (appState.muteDoneSound as boolean) || false,
enhancementModel:
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],

View File

@@ -13,7 +13,6 @@ export type {
ThinkingLevel,
ModelProvider,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
ChatSessionRef,

View File

@@ -47,10 +47,8 @@ const E2E_SETTINGS = {
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
useWorktrees: true,
showProfilesOnly: false,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
phaseModels: {
enhancementModel: { model: 'sonnet' },
@@ -73,7 +71,6 @@ const E2E_SETTINGS = {
spec: 'D',
context: 'C',
settings: 'S',
profiles: 'M',
terminal: 'T',
toggleSidebar: '`',
addFeature: 'N',
@@ -84,7 +81,6 @@ const E2E_SETTINGS = {
projectPicker: 'P',
cyclePrevProject: 'Q',
cycleNextProject: 'E',
addProfile: 'N',
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
@@ -94,48 +90,6 @@ const E2E_SETTINGS = {
githubPrs: 'R',
newTerminalTab: 'Alt+T',
},
aiProfiles: [
{
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus',
thinkingLevel: 'ultrathink',
provider: 'claude',
isBuiltIn: true,
icon: 'Brain',
},
{
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet',
thinkingLevel: 'medium',
provider: 'claude',
isBuiltIn: true,
icon: 'Scale',
},
{
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku',
thinkingLevel: 'none',
provider: 'claude',
isBuiltIn: true,
icon: 'Zap',
},
{
id: 'profile-cursor-refactoring',
name: 'Cursor Refactoring',
description: 'Cursor Composer 1 for refactoring tasks.',
provider: 'cursor',
cursorModel: 'composer-1',
isBuiltIn: true,
icon: 'Sparkles',
},
],
// Default test project using the fixture path - tests can override via route mocking if needed
projects: [
{

View File

@@ -59,7 +59,7 @@ export function Sidebar() {
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
@@ -232,7 +232,6 @@ export function Sidebar() {
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,

View File

@@ -20,5 +20,4 @@ export const SIDEBAR_FEATURE_FLAGS = {
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
} as const;

View File

@@ -5,11 +5,9 @@ import {
LayoutGrid,
Bot,
BookOpen,
UserCircle,
Terminal,
CircleDot,
GitPullRequest,
Zap,
Lightbulb,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
@@ -26,7 +24,6 @@ interface UseNavigationProps {
cycleNextProject: string;
spec: string;
context: string;
profiles: string;
board: string;
agent: string;
terminal: string;
@@ -38,7 +35,6 @@ interface UseNavigationProps {
hideSpecEditor: boolean;
hideContext: boolean;
hideTerminal: boolean;
hideAiProfiles: boolean;
currentProject: Project | null;
projects: Project[];
projectHistory: string[];
@@ -57,7 +53,6 @@ export function useNavigation({
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
@@ -114,12 +109,6 @@ export function useNavigation({
icon: BookOpen,
shortcut: shortcuts.context,
},
{
id: 'profiles',
label: 'AI Profiles',
icon: UserCircle,
shortcut: shortcuts.profiles,
},
];
// Filter out hidden items
@@ -130,9 +119,6 @@ export function useNavigation({
if (item.id === 'context' && hideContext) {
return false;
}
if (item.id === 'profiles' && hideAiProfiles) {
return false;
}
return true;
});
@@ -201,7 +187,6 @@ export function useNavigation({
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
]);

View File

@@ -88,7 +88,6 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
spec: 'Spec Editor',
context: 'Context',
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
ideation: 'Ideation',
githubIssues: 'GitHub Issues',
@@ -102,7 +101,6 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
projectPicker: 'Project Picker',
cyclePrevProject: 'Prev Project',
cycleNextProject: 'Next Project',
addProfile: 'Add Profile',
splitTerminalRight: 'Split Right',
splitTerminalDown: 'Split Down',
closeTerminal: 'Close Terminal',
@@ -116,7 +114,6 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
spec: 'navigation',
context: 'navigation',
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
ideation: 'navigation',
githubIssues: 'navigation',
@@ -130,7 +127,6 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
projectPicker: 'action',
cyclePrevProject: 'action',
cycleNextProject: 'action',
addProfile: 'action',
splitTerminalRight: 'action',
splitTerminalDown: 'action',
closeTerminal: 'action',

View File

@@ -73,8 +73,6 @@ export function BoardView() {
maxConcurrency,
setMaxConcurrency,
defaultSkipTests,
showProfilesOnly,
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardViewMode,
@@ -1299,8 +1297,6 @@ export function BoardView() {
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
/>
{/* Board Background Modal */}
@@ -1348,8 +1344,6 @@ export function BoardView() {
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
@@ -1364,8 +1358,6 @@ export function BoardView() {
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles}
allFeatures={hookFeatures}
/>

View File

@@ -32,24 +32,17 @@ import {
ModelAlias,
ThinkingLevel,
FeatureImage,
AIProfile,
PlanningMode,
Feature,
} from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
import {
supportsReasoningEffort,
PROVIDER_PREFIXES,
isCursorModel,
isClaudeModel,
} from '@automaker/types';
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
AncestorContextSection,
ProfileTypeahead,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
@@ -60,7 +53,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router';
import {
getAncestors,
formatAncestorContextForPrompt,
@@ -100,8 +92,6 @@ interface AddFeatureDialogProps {
defaultBranch?: string;
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
parentFeature?: Feature | null;
allFeatures?: Feature[];
}
@@ -118,13 +108,10 @@ export function AddFeatureDialog({
defaultBranch = 'main',
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
parentFeature = null,
allFeatures = [],
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
const [workMode, setWorkMode] = useState<WorkMode>('current');
// Form state
@@ -139,7 +126,6 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
@@ -163,7 +149,7 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId } = useAppStore();
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
@@ -177,24 +163,12 @@ export function AddFeatureDialog({
wasOpenRef.current = open;
if (justOpened) {
const defaultProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
setSkipTests(defaultSkipTests);
setBranchName(defaultBranch || '');
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
// Set model from default profile or fallback
if (defaultProfile) {
setSelectedProfileId(defaultProfile.id);
applyProfileToModel(defaultProfile);
} else {
setSelectedProfileId(undefined);
setModelEntry({ model: 'opus' });
}
setModelEntry({ model: 'opus' });
// Initialize ancestors for spawn mode
if (parentFeature) {
@@ -212,41 +186,12 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
parentFeature,
allFeatures,
]);
const applyProfileToModel = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModelEntry({ model: cursorModel as ModelAlias });
} else if (profile.provider === 'codex') {
setModelEntry({
model: profile.codexModel || 'codex-gpt-5.2-codex',
reasoningEffort: 'none',
});
} else if (profile.provider === 'opencode') {
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
} else {
// Claude
setModelEntry({
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleProfileSelect = (profile: AIProfile) => {
setSelectedProfileId(profile.id);
applyProfileToModel(profile);
};
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
// Clear profile selection when manually changing model
setSelectedProfileId(undefined);
};
const buildFeatureData = (): FeatureData | null => {
@@ -327,7 +272,6 @@ export function AddFeatureDialog({
setSkipTests(defaultSkipTests);
setBranchName('');
setPriority(2);
setSelectedProfileId(undefined);
setModelEntry({ model: 'opus' });
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
@@ -538,31 +482,14 @@ export function AddFeatureDialog({
<span>AI & Execution</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Profile</Label>
<ProfileTypeahead
profiles={aiProfiles}
selectedProfileId={selectedProfileId}
onSelect={handleProfileSelect}
placeholder="Select profile..."
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: '/profiles' });
}}
testIdPrefix="add-feature-profile"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div

View File

@@ -34,21 +34,13 @@ import {
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn, modelSupportsThinking } from '@/lib/utils';
import {
Feature,
ModelAlias,
ThinkingLevel,
AIProfile,
useAppStore,
PlanningMode,
} from '@/store/app-store';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
ProfileTypeahead,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
@@ -61,13 +53,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import {
isCursorModel,
isClaudeModel,
PROVIDER_PREFIXES,
supportsReasoningEffort,
} from '@automaker/types';
import { useNavigate } from '@tanstack/react-router';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
const logger = createLogger('EditFeatureDialog');
@@ -99,8 +85,6 @@ interface EditFeatureDialogProps {
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
currentBranch?: string;
isMaximized: boolean;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
allFeatures: Feature[];
}
@@ -113,11 +97,8 @@ export function EditFeatureDialog({
branchCardCounts,
currentBranch,
isMaximized,
showProfilesOnly,
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
// Derive initial workMode from feature's branchName
const [workMode, setWorkMode] = useState<WorkMode>(() => {
@@ -140,7 +121,6 @@ export function EditFeatureDialog({
);
// Model selection state
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
thinkingLevel: feature?.thinkingLevel || 'none',
@@ -180,7 +160,6 @@ export function EditFeatureDialog({
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
setSelectedProfileId(undefined);
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
@@ -188,35 +167,8 @@ export function EditFeatureDialog({
}
}, [feature]);
const applyProfileToModel = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModelEntry({ model: cursorModel as ModelAlias });
} else if (profile.provider === 'codex') {
setModelEntry({
model: profile.codexModel || 'codex-gpt-5.2-codex',
reasoningEffort: 'none',
});
} else if (profile.provider === 'opencode') {
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
} else {
// Claude
setModelEntry({
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleProfileSelect = (profile: AIProfile) => {
setSelectedProfileId(profile.id);
applyProfileToModel(profile);
};
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
// Clear profile selection when manually changing model
setSelectedProfileId(undefined);
};
const handleUpdate = () => {
@@ -554,31 +506,14 @@ export function EditFeatureDialog({
<span>AI & Execution</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Profile</Label>
<ProfileTypeahead
profiles={aiProfiles}
selectedProfileId={selectedProfileId}
onSelect={handleProfileSelect}
placeholder="Select profile..."
showManageLink
onManageLinkClick={() => {
onClose();
navigate({ to: '/profiles' });
}}
testIdPrefix="edit-feature-profile"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Model</Label>
<PhaseModelSelector
value={modelEntry}
onChange={handleModelChange}
compact
align="end"
/>
</div>
<div

View File

@@ -12,10 +12,10 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, AIProfile, PlanningMode } from '@/store/app-store';
import { ProfileSelect, TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, PROVIDER_PREFIXES, type PhaseModelEntry } from '@automaker/types';
import { isCursorModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils';
interface MassEditDialogProps {
@@ -23,8 +23,6 @@ interface MassEditDialogProps {
onClose: () => void;
selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>;
showProfilesOnly: boolean;
aiProfiles: AIProfile[];
}
interface ApplyState {
@@ -98,14 +96,7 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
);
}
export function MassEditDialog({
open,
onClose,
selectedFeatures,
onApply,
showProfilesOnly,
aiProfiles,
}: MassEditDialogProps) {
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
// Track which fields to apply
@@ -149,26 +140,6 @@ export function MassEditDialog({
}
}, [open, selectedFeatures]);
const handleModelSelect = (newModel: string) => {
const isCursor = isCursorModel(newModel);
setModel(newModel as ModelAlias);
if (isCursor || !modelSupportsThinking(newModel)) {
setThinkingLevel('none');
}
};
const handleProfileSelect = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setModel(cursorModel as ModelAlias);
setThinkingLevel('none');
} else {
setModel((profile.model || 'sonnet') as ModelAlias);
setThinkingLevel(profile.thinkingLevel || 'none');
}
setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true }));
};
const handleApply = async () => {
const updates: Partial<Feature> = {};
@@ -208,29 +179,11 @@ export function MassEditDialog({
</DialogHeader>
<div className="py-4 pr-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Quick Select Profile</Label>
<p className="text-xs text-muted-foreground mb-2">
Selecting a profile will automatically enable model settings
</p>
<ProfileSelect
profiles={aiProfiles}
selectedModel={model}
selectedThinkingLevel={thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? model : undefined}
onSelect={handleProfileSelect}
testIdPrefix="mass-edit-profile"
/>
</div>
)}
{/* Model Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">AI Model</Label>
<p className="text-xs text-muted-foreground mb-2">
Or select a specific model configuration
Select a specific model configuration
</p>
<PhaseModelSelector
value={{ model, thinkingLevel }}

View File

@@ -2,9 +2,6 @@ export * from './model-constants';
export * from './model-selector';
export * from './thinking-level-selector';
export * from './reasoning-effort-selector';
export * from './profile-quick-select';
export * from './profile-select';
export * from './profile-typeahead';
export * from './testing-tab-content';
export * from './priority-selector';
export * from './priority-select';

View File

@@ -1,155 +0,0 @@
import { Label } from '@/components/ui/label';
import { Brain, UserCircle, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
profileHasThinking,
PROVIDER_PREFIXES,
getCodexModelLabel,
} from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
if (profile.provider === 'codex') {
// For Codex, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void; // Changed to pass full profile
testIdPrefix?: string;
showManageLink?: boolean;
onManageLinkClick?: () => void;
}
export function ProfileQuickSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
// Show both Claude and Cursor profiles
const allProfiles = profiles;
if (allProfiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<UserCircle className="w-4 h-4 text-brand-500" />
Quick Select Profile
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
Presets
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{allProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected = isProfileSelected(profile);
const isCursorProfile = profile.provider === 'cursor';
return (
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
? 'bg-brand-500/10 border-brand-500 text-foreground'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${profile.id}`}
>
<div
className={cn(
'w-7 h-7 rounded flex items-center justify-center shrink-0',
isCursorProfile ? 'bg-amber-500/10' : 'bg-primary/10'
)}
>
{isCursorProfile ? (
<Terminal className="w-4 h-4 text-amber-500" />
) : (
IconComponent && <IconComponent className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) && ` + ${getProfileThinkingDisplay(profile)}`}
</p>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Or customize below.
{showManageLink && onManageLinkClick && (
<>
{' '}
Manage profiles in{' '}
<button
type="button"
onClick={onManageLinkClick}
className="text-brand-500 hover:underline"
>
AI Profiles
</button>
</>
)}
</p>
</div>
);
}

View File

@@ -1,187 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Brain, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
profileHasThinking,
PROVIDER_PREFIXES,
getCodexModelLabel,
} from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
if (profile.provider === 'codex') {
// For Codex, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileSelectProps {
profiles: AIProfile[];
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void;
testIdPrefix?: string;
className?: string;
disabled?: boolean;
}
/**
* ProfileSelect - Compact dropdown selector for AI profiles
*
* A lightweight alternative to ProfileQuickSelect for contexts where
* space is limited (e.g., mass edit, bulk operations).
*
* Shows icon + profile name in dropdown, with model details below.
*
* @example
* ```tsx
* <ProfileSelect
* profiles={aiProfiles}
* selectedModel={model}
* selectedThinkingLevel={thinkingLevel}
* selectedCursorModel={isCurrentModelCursor ? model : undefined}
* onSelect={handleProfileSelect}
* testIdPrefix="mass-edit-profile"
* />
* ```
*/
export function ProfileSelect({
profiles,
selectedModel,
selectedThinkingLevel,
selectedCursorModel,
onSelect,
testIdPrefix = 'profile-select',
className,
disabled = false,
}: ProfileSelectProps) {
if (profiles.length === 0) {
return null;
}
// Check if a profile is selected
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles
return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
};
const selectedProfile = profiles.find(isProfileSelected);
return (
<div className={cn('space-y-2', className)}>
<Select
value={selectedProfile?.id || 'none'}
onValueChange={(value: string) => {
if (value !== 'none') {
const profile = profiles.find((p) => p.id === value);
if (profile) {
onSelect(profile);
}
}
}}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedProfile ? (
<div className="flex items-center gap-2">
{selectedProfile.provider === 'cursor' ? (
<Terminal className="h-4 w-4 text-amber-500" />
) : (
(() => {
const IconComponent = selectedProfile.icon
? PROFILE_ICONS[selectedProfile.icon]
: Brain;
return <IconComponent className="h-4 w-4 text-primary" />;
})()
)}
<span>{selectedProfile.name}</span>
</div>
) : (
<span className="text-muted-foreground">Select a profile...</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-muted-foreground">
No profile selected
</SelectItem>
{profiles.map((profile) => {
const isCursorProfile = profile.provider === 'cursor';
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
return (
<SelectItem
key={profile.id}
value={profile.id}
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2">
{isCursorProfile ? (
<Terminal className="h-3.5 w-3.5 text-amber-500" />
) : (
<IconComponent className="h-3.5 w-3.5 text-primary" />
)}
<div className="flex flex-col">
<span className="text-sm">{profile.name}</span>
<span className="text-[10px] text-muted-foreground">
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) &&
` + ${getProfileThinkingDisplay(profile)}`}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectedProfile && (
<p className="text-xs text-muted-foreground">
{getProfileModelDisplay(selectedProfile)}
{getProfileThinkingDisplay(selectedProfile) &&
` + ${getProfileThinkingDisplay(selectedProfile)}`}
</p>
)}
</div>
);
}

View File

@@ -1,237 +0,0 @@
import * as React from 'react';
import { Check, ChevronsUpDown, UserCircle, Settings2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types';
import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
if (profile.provider === 'codex') {
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
}
if (profile.provider === 'opencode') {
// Extract a short label from the opencode model
const modelId = profile.opencodeModel || '';
if (modelId.includes('/')) {
const parts = modelId.split('/');
return parts[parts.length - 1].split('.')[0] || modelId;
}
return modelId;
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor' || profile.provider === 'codex') {
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileTypeaheadProps {
profiles: AIProfile[];
selectedProfileId?: string;
onSelect: (profile: AIProfile) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
showManageLink?: boolean;
onManageLinkClick?: () => void;
testIdPrefix?: string;
}
export function ProfileTypeahead({
profiles,
selectedProfileId,
onSelect,
placeholder = 'Select profile...',
className,
disabled = false,
showManageLink = false,
onManageLinkClick,
testIdPrefix = 'profile-typeahead',
}: ProfileTypeaheadProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const selectedProfile = React.useMemo(
() => profiles.find((p) => p.id === selectedProfileId),
[profiles, selectedProfileId]
);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [selectedProfileId]);
// Filter profiles based on input
const filteredProfiles = React.useMemo(() => {
if (!inputValue) return profiles;
const lower = inputValue.toLowerCase();
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(lower) ||
p.description?.toLowerCase().includes(lower) ||
p.provider.toLowerCase().includes(lower)
);
}, [profiles, inputValue]);
const handleSelect = (profile: AIProfile) => {
onSelect(profile);
setInputValue('');
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between h-9', className)}
data-testid={`${testIdPrefix}-trigger`}
>
<span className="flex items-center gap-2 truncate">
{selectedProfile ? (
<>
{(() => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[selectedProfile.provider];
return ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
);
})()}
<span className="truncate">{selectedProfile.name}</span>
</>
) : (
<>
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{placeholder}</span>
</>
)}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: Math.max(triggerWidth, 280) }}
data-testid={`${testIdPrefix}-content`}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search profiles..."
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>No profile found.</CommandEmpty>
<CommandGroup>
{filteredProfiles.map((profile) => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider];
const isSelected = profile.id === selectedProfileId;
const modelDisplay = getProfileModelDisplay(profile);
const thinkingDisplay = getProfileThinkingDisplay(profile);
return (
<CommandItem
key={profile.id}
value={profile.id}
onSelect={() => handleSelect(profile)}
className="flex items-center gap-2 py-2"
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{profile.name}</span>
<span className="text-xs text-muted-foreground truncate">
{modelDisplay}
{thinkingDisplay && (
<span className="text-amber-500"> + {thinkingDisplay}</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{profile.isBuiltIn && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Built-in
</Badge>
)}
<Check className={cn('h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')} />
</div>
</CommandItem>
);
})}
</CommandGroup>
{showManageLink && onManageLinkClick && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false);
onManageLinkClick();
}}
className="text-muted-foreground"
data-testid={`${testIdPrefix}-manage-link`}
>
<Settings2 className="w-4 h-4 mr-2" />
Manage AI Profiles
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -26,8 +26,7 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
@@ -45,12 +44,6 @@ export function GitHubIssuesView() {
onShowValidationDialogChange: setShowValidationDialog,
});
// Get default AI profile for task creation
const defaultProfile = useMemo(() => {
if (!defaultAIProfileId) return null;
return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null;
}, [defaultAIProfileId, aiProfiles]);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
@@ -99,9 +92,6 @@ export function GitHubIssuesView() {
.filter(Boolean)
.join('\n');
// Use profile default model
const featureModel = defaultProfile?.model ?? 'opus';
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
@@ -110,8 +100,8 @@ export function GitHubIssuesView() {
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: featureModel,
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
model: 'opus',
thinkingLevel: 'none' as const,
branchName: currentBranch,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -129,7 +119,7 @@ export function GitHubIssuesView() {
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
[currentProject?.path, defaultProfile, currentBranch]
[currentProject?.path, currentBranch]
);
if (loading) {

View File

@@ -1,275 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { useAppStore, AIProfile } from '@/store/app-store';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components';
export function ProfilesView() {
const {
aiProfiles,
addAIProfile,
updateAIProfile,
removeAIProfile,
reorderAIProfiles,
resetAIProfiles,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
const [profileToDelete, setProfileToDelete] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]);
const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, 'id'>) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success('Profile created', {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, 'id'>) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success('Profile updated', {
description: `Updated "${profile.name}" profile`,
});
}
};
const confirmDeleteProfile = () => {
if (!profileToDelete) return;
removeAIProfile(profileToDelete.id);
toast.success('Profile deleted', {
description: `Deleted "${profileToDelete.name}" profile`,
});
setProfileToDelete(null);
};
const handleResetProfiles = () => {
resetAIProfiles();
toast.success('Profiles refreshed', {
description: 'Default profiles have been updated to the latest version',
});
};
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcutsList.push({
key: shortcuts.addProfile,
action: () => setShowAddDialog(true),
description: 'Create new profile',
});
return shortcutsList;
}, [shortcuts]);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="profiles-view">
{/* Header Section */}
<ProfilesHeader
onResetProfiles={handleResetProfiles}
onAddProfile={() => setShowAddDialog(true)}
addProfileHotkey={shortcuts.addProfile}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Custom Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div
className="group rounded-xl border border-dashed border-border p-8 text-center transition-all duration-300 hover:border-primary hover:bg-primary/5 cursor-pointer"
onClick={() => setShowAddDialog(true)}
>
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50 transition-all duration-300 group-hover:text-primary group-hover:opacity-100 group-hover:scale-110 group-hover:rotate-12" />
<p className="text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
No custom profiles yet. Create one to get started!
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => setProfileToDelete(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Built-in Profiles</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be edited or deleted.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={builtInProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent
data-testid="add-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>Define a reusable model configuration preset.</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
hotkeyActive={showAddDialog}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog open={!!editingProfile} onOpenChange={() => setEditingProfile(null)}>
<DialogContent
data-testid="edit-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
hotkeyActive={!!editingProfile}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={!!profileToDelete}
onOpenChange={(open) => !open && setProfileToDelete(null)}
onConfirm={confirmDeleteProfile}
title="Delete Profile"
description={
profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: ''
}
confirmText="Delete Profile"
testId="delete-profile-confirm-dialog"
confirmTestId="confirm-delete-profile-button"
/>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { SortableProfileCard } from './sortable-profile-card';
export { ProfileForm } from './profile-form';
export { ProfilesHeader } from './profiles-header';

View File

@@ -1,560 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import { toast } from 'sonner';
import type {
AIProfile,
ModelAlias,
ThinkingLevel,
ModelProvider,
CursorModelId,
CodexModelId,
OpencodeModelId,
} from '@automaker/types';
import {
CURSOR_MODEL_MAP,
cursorModelHasThinking,
CODEX_MODEL_MAP,
OPENCODE_MODELS,
DEFAULT_OPENCODE_MODEL,
} from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
interface ProfileFormProps {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, 'id'>) => void;
onCancel: () => void;
isEditing: boolean;
hotkeyActive: boolean;
}
export function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
hotkeyActive,
}: ProfileFormProps) {
const { enabledCursorModels } = useAppStore();
const [formData, setFormData] = useState({
name: profile.name || '',
description: profile.description || '',
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
// OpenCode-specific
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
icon: profile.icon || 'Brain',
});
// Sync formData with profile prop when it changes
useEffect(() => {
setFormData({
name: profile.name || '',
description: profile.description || '',
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
// OpenCode-specific
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
icon: profile.icon || 'Brain',
});
}, [profile]);
const supportsThinking = formData.provider === 'claude' && modelSupportsThinking(formData.model);
const handleProviderChange = (provider: ModelProvider) => {
setFormData({
...formData,
provider,
// Only reset Claude fields when switching TO Claude; preserve otherwise
model: provider === 'claude' ? 'sonnet' : formData.model,
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
// Reset cursor/codex/opencode models when switching to that provider
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
codexModel:
provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel,
opencodeModel:
provider === 'opencode'
? (DEFAULT_OPENCODE_MODEL as OpencodeModelId)
: formData.opencodeModel,
});
};
const handleModelChange = (model: ModelAlias) => {
setFormData({
...formData,
model,
});
};
const handleCursorModelChange = (cursorModel: CursorModelId) => {
setFormData({
...formData,
cursorModel,
});
};
const handleCodexModelChange = (codexModel: CodexModelId) => {
setFormData({
...formData,
codexModel,
});
};
const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => {
setFormData({
...formData,
opencodeModel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('Please enter a profile name');
return;
}
// Ensure model is always set for Claude profiles
const validModels: ModelAlias[] = ['haiku', 'sonnet', 'opus'];
const finalModel =
formData.provider === 'claude'
? validModels.includes(formData.model)
? formData.model
: 'sonnet'
: undefined;
const baseProfile = {
name: formData.name.trim(),
description: formData.description.trim(),
provider: formData.provider,
isBuiltIn: false,
icon: formData.icon,
};
if (formData.provider === 'cursor') {
onSave({
...baseProfile,
cursorModel: formData.cursorModel,
});
} else if (formData.provider === 'codex') {
onSave({
...baseProfile,
codexModel: formData.codexModel,
});
} else if (formData.provider === 'opencode') {
onSave({
...baseProfile,
opencodeModel: formData.opencodeModel,
});
} else {
onSave({
...baseProfile,
model: finalModel as ModelAlias,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
});
}
};
return (
<>
<div className="overflow-y-auto flex-1 min-h-0 space-y-4 pr-3 -mr-3 pl-1">
{/* Name */}
<div className="mt-2 space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center border transition-colors',
formData.icon === name
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Provider Selection */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-claude"
>
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-cursor"
>
<CursorIcon className="w-4 h-4" />
Cursor
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-codex"
>
<OpenAIIcon className="w-4 h-4" />
Codex
</button>
<button
type="button"
onClick={() => handleProviderChange('opencode')}
className={cn(
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'opencode'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-opencode"
>
<OpenCodeIcon className="w-4 h-4" />
OpenCode
</button>
</div>
</div>
{/* Claude Model Selection */}
{formData.provider === 'claude' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
</div>
</div>
)}
{/* Cursor Model Selection */}
{formData.provider === 'cursor' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<CursorIcon className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<div className="flex flex-col gap-2">
{enabledCursorModels.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Cursor models enabled. Enable models in Settings AI Providers.
</div>
) : (
Object.entries(CURSOR_MODEL_MAP)
.filter(([id]) => enabledCursorModels.includes(id as CursorModelId))
.map(([id, config]) => (
<button
key={id}
type="button"
onClick={() => handleCursorModelChange(id as CursorModelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.cursorModel === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`cursor-model-select-${id}`}
>
<span>{config.label}</span>
<div className="flex gap-1">
{config.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.cursorModel === id
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
<Badge
variant="secondary"
className={cn(
'text-xs',
formData.cursorModel === id && 'bg-primary-foreground/20'
)}
>
Tier
</Badge>
</div>
</button>
))
)}
</div>
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
<p className="text-xs text-muted-foreground">
This model has built-in extended thinking capabilities.
</p>
)}
</div>
)}
{/* Codex Model Selection */}
{formData.provider === 'codex' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<OpenAIIcon className="w-4 h-4 text-primary" />
Codex Model
</Label>
<div className="flex flex-col gap-2">
{Object.entries(CODEX_MODEL_MAP).map(([_, modelId]) => {
const modelConfig = {
label: modelId,
badge: 'Standard' as const,
hasReasoning: false,
};
return (
<button
key={modelId}
type="button"
onClick={() => handleCodexModelChange(modelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.codexModel === modelId
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`codex-model-select-${modelId}`}
>
<span>{modelConfig.label}</span>
<div className="flex gap-1">
{modelConfig.hasReasoning && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Reasoning
</Badge>
)}
<Badge
variant="outline"
className={cn(
'text-xs',
formData.codexModel === modelId
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{modelConfig.badge}
</Badge>
</div>
</button>
);
})}
</div>
</div>
)}
{/* OpenCode Model Selection */}
{formData.provider === 'opencode' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<OpenCodeIcon className="w-4 h-4 text-primary" />
OpenCode Model
</Label>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto">
{OPENCODE_MODELS.map((model) => (
<button
key={model.id}
type="button"
onClick={() => handleOpencodeModelChange(model.id)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.opencodeModel === model.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`opencode-model-select-${model.id}`}
>
<div className="flex flex-col items-start gap-0.5">
<span>{model.label}</span>
<span
className={cn(
'text-xs',
formData.opencodeModel === model.id
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{model.description}
</span>
</div>
<Badge
variant="outline"
className={cn(
'text-xs capitalize shrink-0',
formData.opencodeModel === model.id
? 'border-primary-foreground/50 text-primary-foreground'
: model.tier === 'free'
? 'border-green-500/50 text-green-600 dark:text-green-400'
: model.tier === 'premium'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{model.tier}
</Badge>
</button>
))}
</div>
</div>
)}
{/* Claude Thinking Level */}
{formData.provider === 'claude' && supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === 'ultrathink') {
toast.warning('Ultrathink uses extensive reasoning', {
description:
'Best for complex architecture, migrations, or deep debugging (~$0.48/task).',
duration: 4000,
});
}
}}
className={cn(
'flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.thinkingLevel === id
? 'bg-amber-500 text-white border-amber-400'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
</div>
{/* Actions */}
<DialogFooter className="pt-4 border-t border-border mt-4 shrink-0">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<HotkeyButton
onClick={handleSubmit}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={hotkeyActive}
data-testid="save-profile-button"
>
{isEditing ? 'Save Changes' : 'Create Profile'}
</HotkeyButton>
</DialogFooter>
</>
);
}

View File

@@ -1,55 +0,0 @@
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { UserCircle, Plus, RefreshCw } from 'lucide-react';
interface ProfilesHeaderProps {
onResetProfiles: () => void;
onAddProfile: () => void;
addProfileHotkey: string;
}
export function ProfilesHeader({
onResetProfiles,
onAddProfile,
addProfileHotkey,
}: ProfilesHeaderProps) {
return (
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">AI Profiles</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onResetProfiles}
data-testid="refresh-profiles-button"
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Refresh Defaults
</Button>
<HotkeyButton
onClick={onAddProfile}
hotkey={addProfileHotkey}
hotkeyActive={false}
data-testid="add-profile-button"
>
<Plus className="w-4 h-4 mr-2" />
New Profile
</HotkeyButton>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,142 +0,0 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GripVertical, Lock, Pencil, Trash2 } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types';
import { PROFILE_ICONS } from '../constants';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
interface SortableProfileCardProps {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}
export function SortableProfileCard({ profile, onEdit, onDelete }: SortableProfileCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: profile.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getDefaultIcon = () => {
if (profile.provider === 'cursor') return CursorIcon;
if (profile.provider === 'codex') return OpenAIIcon;
return AnthropicIcon;
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : getDefaultIcon();
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all',
isDragging && 'shadow-lg',
profile.isBuiltIn
? 'border-border/50'
: 'border-border hover:border-primary/50 hover:shadow-sm'
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
aria-label={`Reorder ${profile.name} profile`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-primary/10">
{IconComponent && <IconComponent className="w-5 h-5 text-primary" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{profile.description}</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Provider badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-border text-muted-foreground bg-muted/50 flex items-center gap-1">
{profile.provider === 'cursor' ? (
<CursorIcon className="w-3 h-3" />
) : profile.provider === 'codex' ? (
<OpenAIIcon className="w-3 h-3" />
) : (
<AnthropicIcon className="w-3 h-3" />
)}
{profile.provider === 'cursor'
? 'Cursor'
: profile.provider === 'codex'
? 'Codex'
: 'Claude'}
</span>
{/* Model badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.provider === 'cursor'
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label ||
profile.cursorModel ||
'auto'
: profile.provider === 'codex'
? getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex')
: profile.model || 'sonnet'}
</span>
{/* Thinking badge - works for both providers */}
{profileHasThinking(profile) && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.provider === 'cursor' ? 'Thinking' : profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
aria-label={`Edit ${profile.name} profile`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
aria-label={`Delete ${profile.name} profile`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
// Icon mapping for profiles
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
Anthropic: AnthropicIcon,
Cursor: CursorIcon,
Codex: OpenAIIcon,
};
// Available icons for selection
export const ICON_OPTIONS = [
{ name: 'Brain', icon: Brain },
{ name: 'Zap', icon: Zap },
{ name: 'Scale', icon: Scale },
{ name: 'Cpu', icon: Cpu },
{ name: 'Rocket', icon: Rocket },
{ name: 'Sparkles', icon: Sparkles },
{ name: 'Anthropic', icon: AnthropicIcon },
{ name: 'Cursor', icon: CursorIcon },
{ name: 'Codex', icon: OpenAIIcon },
];
// Model options for the form
export const CLAUDE_MODELS: { id: ModelAlias; label: string }[] = [
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },
];
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: 'none', label: 'None' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
];

View File

@@ -1,48 +0,0 @@
import type { ModelAlias, ModelProvider, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
// Helper to determine provider from model (legacy, always returns 'claude')
export function getProviderFromModel(model: ModelAlias): ModelProvider {
return 'claude';
}
/**
* Validate an AI profile for completeness and correctness
*/
export function validateProfile(profile: Partial<AIProfile>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Name is required
if (!profile.name?.trim()) {
errors.push('Profile name is required');
}
// Provider must be valid
if (!profile.provider || !['claude', 'cursor'].includes(profile.provider)) {
errors.push('Invalid provider');
}
// Claude-specific validation
if (profile.provider === 'claude') {
if (!profile.model) {
errors.push('Claude model is required');
} else if (!['haiku', 'sonnet', 'opus'].includes(profile.model)) {
errors.push('Invalid Claude model');
}
}
// Cursor-specific validation
if (profile.provider === 'cursor') {
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
errors.push('Invalid Cursor model');
}
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -42,8 +42,6 @@ export function SettingsView() {
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
setShowProfilesOnly,
muteDoneSound,
setMuteDoneSound,
currentProject,
@@ -52,9 +50,6 @@ export function SettingsView() {
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
promptCustomization,
@@ -151,23 +146,18 @@ export function SettingsView() {
case 'defaults':
return (
<FeatureDefaultsSection
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case 'account':

View File

@@ -2,7 +2,6 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
Settings2,
TestTube,
GitBranch,
AlertCircle,
@@ -11,7 +10,6 @@ import {
FileText,
ScrollText,
ShieldCheck,
User,
FastForward,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -22,53 +20,38 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
}
export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
return (
<div
className={cn(
@@ -194,71 +177,6 @@ export function FeatureDefaultsSection({
{/* Separator */}
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
{/* Default AI Profile */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<User className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default AI Profile</Label>
<Select
value={defaultAIProfileId ?? 'none'}
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === 'none' ? null : v)}
>
<SelectTrigger className="w-[180px] h-8" data-testid="default-ai-profile-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">None (pick manually)</span>
</SelectItem>
{aiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<span>{profile.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{selectedProfile
? `New features will use the "${selectedProfile.name}" profile (${selectedProfile.model}, ${selectedProfile.thinkingLevel} thinking).`
: 'Pre-select an AI profile when creating new features. Choose "None" to pick manually each time.'}
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="show-profiles-only"
checked={showProfilesOnly}
onCheckedChange={(checked) => onShowProfilesOnlyChange(checked === true)}
className="mt-1"
data-testid="show-profiles-only-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-profiles-only"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Settings2 className="w-4 h-4 text-brand-500" />
Show profiles only by default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, the Add Feature dialog will show only AI profiles and hide advanced
model tweaking options. This creates a cleaner, less overwhelming UI.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Automated Testing Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -20,7 +20,6 @@ import {
Image,
TestTube,
Brain,
Users,
} from 'lucide-react';
interface WikiSection {
@@ -236,12 +235,6 @@ export function WikiView() {
description:
'Each feature runs in its own git worktree for safe parallel development.',
},
{
icon: Users,
title: 'AI Profiles',
description:
'Pre-configured model + thinking level combinations for different task types.',
},
{
icon: Terminal,
title: 'Integrated Terminal',
@@ -424,10 +417,6 @@ export function WikiView() {
file: 'views/terminal-view/',
desc: 'Integrated terminal with splits and tabs',
},
{
file: 'views/profiles-view.tsx',
desc: 'AI profile management (model + thinking presets)',
},
{
file: 'store/app-store.ts',
desc: 'Central Zustand state management',
@@ -528,12 +517,6 @@ export function WikiView() {
steps
</p>
</li>
<li className="text-foreground">
<strong>Configure AI Profile</strong>
<p className="text-muted-foreground ml-5 mt-1">
Choose an AI profile or customize model/thinking settings per feature
</p>
</li>
<li className="text-foreground">
<strong>Start Implementation</strong>
<p className="text-muted-foreground ml-5 mt-1">
@@ -555,9 +538,6 @@ export function WikiView() {
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)
</li>
<li>Enable git worktree isolation for parallel feature development</li>
<li>
Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
</li>
<li>Keep your app spec up to date as your project evolves</li>
</ul>
</div>

View File

@@ -145,10 +145,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
enableDependencyBlocking: state.enableDependencyBlocking as boolean,
skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean,
useWorktrees: state.useWorktrees as boolean,
showProfilesOnly: state.showProfilesOnly as boolean,
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
defaultAIProfileId: state.defaultAIProfileId as string | null,
muteDoneSound: state.muteDoneSound as boolean,
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
validationModel: state.validationModel as GlobalSettings['validationModel'],
@@ -157,7 +155,6 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
aiProfiles: state.aiProfiles as GlobalSettings['aiProfiles'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
projects: state.projects as GlobalSettings['projects'],
@@ -199,17 +196,6 @@ export function localStorageHasMoreData(
return true;
}
// Check if localStorage has AI profiles that server doesn't
const localProfiles = localSettings.aiProfiles || [];
const serverProfiles = serverSettings.aiProfiles || [];
if (localProfiles.length > 0 && serverProfiles.length === 0) {
logger.info(
`localStorage has ${localProfiles.length} AI profiles, server has none - will merge`
);
return true;
}
return false;
}
@@ -235,14 +221,6 @@ export function mergeSettings(
merged.projects = localSettings.projects;
}
if (
(!serverSettings.aiProfiles || serverSettings.aiProfiles.length === 0) &&
localSettings.aiProfiles &&
localSettings.aiProfiles.length > 0
) {
merged.aiProfiles = localSettings.aiProfiles;
}
if (
(!serverSettings.trashedProjects || serverSettings.trashedProjects.length === 0) &&
localSettings.trashedProjects &&
@@ -313,12 +291,8 @@ export async function performSettingsMigration(
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
// Get localStorage data
const localSettings = parseLocalStorageSettings();
logger.info(
`localStorage has ${localSettings?.projects?.length ?? 0} projects, ${localSettings?.aiProfiles?.length ?? 0} profiles`
);
logger.info(
`Server has ${serverSettings.projects?.length ?? 0} projects, ${serverSettings.aiProfiles?.length ?? 0} profiles`
);
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
// Check if migration has already been completed
if (serverSettings.localStorageMigrated) {
@@ -399,9 +373,7 @@ export function useSettingsMigration(): MigrationState {
// Always try to get localStorage data first (in case we need to merge/migrate)
const localSettings = parseLocalStorageSettings();
logger.info(
`localStorage has ${localSettings?.projects?.length ?? 0} projects, ${localSettings?.aiProfiles?.length ?? 0} profiles`
);
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
// Check if server has settings files
const status = await api.settings.getStatus();
@@ -431,9 +403,7 @@ export function useSettingsMigration(): MigrationState {
const global = await api.settings.getGlobal();
if (global.success && global.settings) {
serverSettings = global.settings as unknown as GlobalSettings;
logger.info(
`Server has ${serverSettings.projects?.length ?? 0} projects, ${serverSettings.aiProfiles?.length ?? 0} profiles`
);
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
}
} catch (error) {
logger.error('Failed to fetch server settings:', error);
@@ -561,10 +531,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
useWorktrees: settings.useWorktrees ?? true,
showProfilesOnly: settings.showProfilesOnly ?? false,
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
defaultAIProfileId: settings.defaultAIProfileId ?? null,
muteDoneSound: settings.muteDoneSound ?? false,
enhancementModel: settings.enhancementModel ?? 'sonnet',
validationModel: settings.validationModel ?? 'opus',
@@ -577,7 +545,6 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
...current.keyboardShortcuts,
...(settings.keyboardShortcuts as unknown as Partial<typeof current.keyboardShortcuts>),
},
aiProfiles: settings.aiProfiles ?? [],
mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {},
projects,
@@ -620,10 +587,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
@@ -631,7 +596,6 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
autoLoadClaudeMd: state.autoLoadClaudeMd,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization,
projects: state.projects,

View File

@@ -37,10 +37,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enableDependencyBlocking',
'skipVerificationInAutoMode',
'useWorktrees',
'showProfilesOnly',
'defaultPlanningMode',
'defaultRequirePlanApproval',
'defaultAIProfileId',
'muteDoneSound',
'enhancementModel',
'validationModel',
@@ -49,7 +47,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
'cursorDefaultModel',
'autoLoadClaudeMd',
'keyboardShortcuts',
'aiProfiles',
'mcpServers',
'promptCustomization',
'projects',
@@ -388,10 +385,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
useWorktrees: serverSettings.useWorktrees,
showProfilesOnly: serverSettings.showProfilesOnly,
defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultAIProfileId: serverSettings.defaultAIProfileId,
muteDoneSound: serverSettings.muteDoneSound,
enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel,
@@ -405,7 +400,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
typeof currentAppState.keyboardShortcuts
>),
},
aiProfiles: serverSettings.aiProfiles,
mcpServers: serverSettings.mcpServers,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,

View File

@@ -1863,14 +1863,11 @@ export class HttpApiClient implements ElectronAPI {
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
showProfilesOnly: boolean;
defaultPlanningMode: string;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
muteDoneSound: boolean;
enhancementModel: string;
keyboardShortcuts: Record<string, string>;
aiProfiles: unknown[];
projects: unknown[];
trashedProjects: unknown[];
projectHistory: string[];

View File

@@ -1,6 +0,0 @@
import { createFileRoute } from '@tanstack/react-router';
import { ProfilesView } from '@/components/views/profiles-view';
export const Route = createFileRoute('/profiles')({
component: ProfilesView,
});

View File

@@ -11,7 +11,6 @@ import type {
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
CursorModelId,
CodexModelId,
OpencodeModelId,
@@ -40,7 +39,6 @@ export type {
PlanningMode,
ThinkingLevel,
ModelProvider,
AIProfile,
FeatureTextFilePath,
FeatureImagePath,
};
@@ -54,7 +52,6 @@ export type ViewMode =
| 'settings'
| 'interview'
| 'context'
| 'profiles'
| 'running-agents'
| 'terminal'
| 'wiki'
@@ -218,7 +215,6 @@ export interface KeyboardShortcuts {
spec: string;
context: string;
settings: string;
profiles: string;
terminal: string;
ideation: string;
githubIssues: string;
@@ -236,7 +232,6 @@ export interface KeyboardShortcuts {
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
addProfile: string;
// Terminal shortcuts
splitTerminalRight: string;
@@ -253,7 +248,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
spec: 'D',
context: 'C',
settings: 'S',
profiles: 'M',
terminal: 'T',
ideation: 'I',
githubIssues: 'G',
@@ -263,7 +257,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
toggleSidebar: '`',
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession)
// This is intentional as they are context-specific and only active in their respective views
addFeature: 'N', // Only active in board view
addContextFile: 'N', // Only active in context view
@@ -273,7 +267,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
projectPicker: 'P', // Global shortcut
cyclePrevProject: 'Q', // Global shortcut
cycleNextProject: 'E', // Global shortcut
addProfile: 'N', // Only active in profiles view
// Terminal shortcuts (only active in terminal view)
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
@@ -539,12 +532,6 @@ export interface AppState {
}>
>;
// AI Profiles
aiProfiles: AIProfile[];
// Profile Display Settings
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
@@ -632,7 +619,6 @@ export interface AppState {
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
// Plan Approval State
// When a plan requires user approval, this holds the pending approval details
@@ -907,9 +893,6 @@ export interface AppActions {
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
// Keyboard Shortcuts actions
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
@@ -961,13 +944,6 @@ export interface AppActions {
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
removeAIProfile: (id: string) => void;
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
resetAIProfiles: () => void;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
@@ -1052,7 +1028,6 @@ export interface AppActions {
setDefaultPlanningMode: (mode: PlanningMode) => void;
setDefaultRequirePlanApproval: (require: boolean) => void;
setDefaultAIProfileId: (profileId: string | null) => void;
// Plan Approval actions
setPendingPlanApproval: (
@@ -1097,52 +1072,6 @@ export interface AppActions {
reset: () => void;
}
// Default built-in AI profiles
const DEFAULT_AI_PROFILES: AIProfile[] = [
// Claude profiles
{
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus',
thinkingLevel: 'ultrathink',
provider: 'claude',
isBuiltIn: true,
icon: 'Brain',
},
{
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet',
thinkingLevel: 'medium',
provider: 'claude',
isBuiltIn: true,
icon: 'Scale',
},
{
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku',
thinkingLevel: 'none',
provider: 'claude',
isBuiltIn: true,
icon: 'Zap',
},
// Cursor profiles
{
id: 'profile-cursor-refactoring',
name: 'Cursor Refactoring',
description: 'Cursor Composer 1 for refactoring tasks.',
provider: 'cursor',
cursorModel: 'composer-1',
isBuiltIn: true,
icon: 'Sparkles',
},
];
const initialState: AppState = {
projects: [],
currentProject: null,
@@ -1175,7 +1104,6 @@ const initialState: AppState = {
useWorktrees: true, // Default to enabled (git worktree isolation)
currentWorktreeByProject: {},
worktreesByProject: {},
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
@@ -1201,7 +1129,6 @@ const initialState: AppState = {
enableSubagents: true, // Subagents enabled by default
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
@@ -1226,7 +1153,6 @@ const initialState: AppState = {
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
@@ -1808,9 +1734,6 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return primary?.branch ?? null;
},
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
// Keyboard Shortcuts actions
setKeyboardShortcut: (key, value) => {
set({
@@ -1967,46 +1890,6 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
await syncSettingsToServer();
},
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] });
},
updateAIProfile: (id, updates) => {
set({
aiProfiles: get().aiProfiles.map((p) => (p.id === id ? { ...p, ...updates } : p)),
});
},
removeAIProfile: (id) => {
// Only allow removing non-built-in profiles
const profile = get().aiProfiles.find((p) => p.id === id);
if (profile && !profile.isBuiltIn) {
// Clear default if this profile was selected
if (get().defaultAIProfileId === id) {
set({ defaultAIProfileId: null });
}
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
}
},
reorderAIProfiles: (oldIndex, newIndex) => {
const profiles = [...get().aiProfiles];
const [movedProfile] = profiles.splice(oldIndex, 1);
profiles.splice(newIndex, 0, movedProfile);
set({ aiProfiles: profiles });
},
resetAIProfiles: () => {
// Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults
const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map((p) => p.id));
const userProfiles = get().aiProfiles.filter(
(p) => !p.isBuiltIn && !defaultProfileIds.has(p.id)
);
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
},
// MCP Server actions
addMCPServer: (server) => {
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2995,7 +2878,6 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
setDefaultAIProfileId: (profileId) => set({ defaultAIProfileId: profileId }),
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),

View File

@@ -1,59 +0,0 @@
/**
* AI Profiles E2E Test
*
* Happy path: Create a new profile
*/
import { test, expect } from '@playwright/test';
import {
setupMockProjectWithProfiles,
waitForNetworkIdle,
navigateToProfiles,
clickNewProfileButton,
fillProfileForm,
saveProfile,
waitForSuccessToast,
countCustomProfiles,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../utils';
test.describe('AI Profiles', () => {
test('should create a new profile', async ({ page }) => {
await setupMockProjectWithProfiles(page, { customProfilesCount: 0 });
await authenticateForTests(page);
await page.goto('/');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await navigateToProfiles(page);
// Get initial custom profile count (may be 0 or more due to server settings hydration)
const initialCount = await countCustomProfiles(page);
await clickNewProfileButton(page);
await fillProfileForm(page, {
name: 'Test Profile',
description: 'A test profile',
icon: 'Brain',
model: 'sonnet',
thinkingLevel: 'medium',
});
await saveProfile(page);
await waitForSuccessToast(page, 'Profile created');
// Wait for the new profile to appear in the list (replaces arbitrary timeout)
// The count should increase by 1 from the initial count
await expect(async () => {
const customCount = await countCustomProfiles(page);
expect(customCount).toBe(initialCount + 1);
}).toPass({ timeout: 5000 });
// Verify the count is correct (final assertion)
const finalCount = await countCustomProfiles(page);
expect(finalCount).toBe(initialCount + 1);
});
});

View File

@@ -79,7 +79,6 @@ export const TEST_IDS = {
navSpec: 'nav-spec',
navContext: 'nav-context',
navAgent: 'nav-agent',
navProfiles: 'nav-profiles',
settingsButton: 'settings-button',
openProjectButton: 'open-project-button',
@@ -88,7 +87,6 @@ export const TEST_IDS = {
specView: 'spec-view',
contextView: 'context-view',
agentView: 'agent-view',
profilesView: 'profiles-view',
settingsView: 'settings-view',
welcomeView: 'welcome-view',
setupView: 'setup-view',
@@ -108,18 +106,6 @@ export const TEST_IDS = {
pathInput: 'path-input',
goToPathButton: 'go-to-path-button',
// Profiles View
addProfileButton: 'add-profile-button',
addProfileDialog: 'add-profile-dialog',
editProfileDialog: 'edit-profile-dialog',
deleteProfileConfirmDialog: 'delete-profile-confirm-dialog',
saveProfileButton: 'save-profile-button',
confirmDeleteProfileButton: 'confirm-delete-profile-button',
cancelDeleteButton: 'cancel-delete-button',
profileNameInput: 'profile-name-input',
profileDescriptionInput: 'profile-description-input',
refreshProfilesButton: 'refresh-profiles-button',
// Context View
contextFileList: 'context-file-list',
addContextButton: 'add-context-button',

View File

@@ -357,7 +357,6 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
@@ -414,7 +413,6 @@ export async function setupProjectWithPathNoWorktrees(
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: false, // Worktree feature DISABLED
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -470,7 +468,6 @@ export async function setupProjectWithStaleWorktree(
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
// This is STALE data - pointing to a worktree path that doesn't exist

View File

@@ -26,7 +26,6 @@ export * from './views/spec-editor';
export * from './views/agent';
export * from './views/settings';
export * from './views/setup';
export * from './views/profiles';
// Component utilities
export * from './components/dialogs';

View File

@@ -829,127 +829,3 @@ export async function setupComplete(page: Page): Promise<void> {
sessionStorage.setItem('automaker-splash-shown', 'true');
}, STORE_VERSIONS);
}
/**
* Set up a mock project with AI profiles for testing the profiles view
* Includes default built-in profiles and optionally custom profiles
*/
export async function setupMockProjectWithProfiles(
page: Page,
options?: {
customProfilesCount?: number;
includeBuiltIn?: boolean;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
// Default built-in profiles (same as DEFAULT_AI_PROFILES from app-store.ts)
// Include all 4 default profiles to match the actual store initialization
const builtInProfiles = [
{
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus' as const,
thinkingLevel: 'ultrathink' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: 'Brain',
},
{
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet' as const,
thinkingLevel: 'medium' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: 'Scale',
},
{
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku' as const,
thinkingLevel: 'none' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: 'Zap',
},
{
id: 'profile-cursor-refactoring',
name: 'Cursor Refactoring',
description: 'Cursor Composer 1 for refactoring tasks.',
provider: 'cursor' as const,
cursorModel: 'composer-1' as const,
isBuiltIn: true,
icon: 'Sparkles',
},
];
// Generate custom profiles if requested
const customProfiles = [];
const customCount = opts?.customProfilesCount ?? 0;
for (let i = 0; i < customCount; i++) {
customProfiles.push({
id: `custom-profile-${i + 1}`,
name: `Custom Profile ${i + 1}`,
description: `Test custom profile ${i + 1}`,
model: ['haiku', 'sonnet', 'opus'][i % 3] as 'haiku' | 'sonnet' | 'opus',
thinkingLevel: ['none', 'low', 'medium', 'high'][i % 4] as
| 'none'
| 'low'
| 'medium'
| 'high',
provider: 'claude' as const,
isBuiltIn: false,
icon: ['Brain', 'Zap', 'Scale', 'Cpu', 'Rocket', 'Sparkles'][i % 6],
});
}
// Combine profiles (built-in first, then custom)
const includeBuiltIn = opts?.includeBuiltIn !== false; // Default to true
const aiProfiles = includeBuiltIn ? [...builtInProfiles, ...customProfiles] : customProfiles;
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: '', google: '', openai: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: aiProfiles,
features: [],
currentView: 'board', // Start at board, will navigate to profiles
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0
};
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Disable splash screen in tests
sessionStorage.setItem('automaker-splash-shown', 'true');
}, options);
}

View File

@@ -1,522 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { clickElement, fillInput } from '../core/interactions';
import { waitForElement, waitForElementHidden } from '../core/waiting';
import { getByTestId } from '../core/elements';
import { navigateToView } from '../navigation/views';
/**
* Navigate to the profiles view
*/
export async function navigateToProfiles(page: Page): Promise<void> {
// Click the profiles navigation button
await navigateToView(page, 'profiles');
// Wait for profiles view to be visible
await page.waitForSelector('[data-testid="profiles-view"]', {
state: 'visible',
timeout: 10000,
});
}
// ============================================================================
// Profile List Operations
// ============================================================================
/**
* Get a specific profile card by ID
*/
export async function getProfileCard(page: Page, profileId: string): Promise<Locator> {
return getByTestId(page, `profile-card-${profileId}`);
}
/**
* Get all profile cards (both built-in and custom)
*/
export async function getProfileCards(page: Page): Promise<Locator> {
return page.locator('[data-testid^="profile-card-"]');
}
/**
* Get only custom profile cards
*/
export async function getCustomProfiles(page: Page): Promise<Locator> {
// Custom profiles don't have the "Built-in" badge
return page.locator('[data-testid^="profile-card-"]').filter({
hasNot: page.locator('text="Built-in"'),
});
}
/**
* Get only built-in profile cards
*/
export async function getBuiltInProfiles(page: Page): Promise<Locator> {
// Built-in profiles have the lock icon and "Built-in" text
return page.locator('[data-testid^="profile-card-"]:has-text("Built-in")');
}
/**
* Count the number of custom profiles
*/
export async function countCustomProfiles(page: Page): Promise<number> {
const customProfiles = await getCustomProfiles(page);
return customProfiles.count();
}
/**
* Count the number of built-in profiles
*/
export async function countBuiltInProfiles(page: Page): Promise<number> {
const builtInProfiles = await getBuiltInProfiles(page);
return await builtInProfiles.count();
}
/**
* Get all custom profile IDs
*/
export async function getCustomProfileIds(page: Page): Promise<string[]> {
const allCards = await page.locator('[data-testid^="profile-card-"]').all();
const customIds: string[] = [];
for (const card of allCards) {
const builtInText = card.locator('text="Built-in"');
const isBuiltIn = (await builtInText.count()) > 0;
if (!isBuiltIn) {
const testId = await card.getAttribute('data-testid');
if (testId) {
// Extract ID from "profile-card-{id}"
const profileId = testId.replace('profile-card-', '');
customIds.push(profileId);
}
}
}
return customIds;
}
/**
* Get the first custom profile ID (useful after creating a profile)
*/
export async function getFirstCustomProfileId(page: Page): Promise<string | null> {
const ids = await getCustomProfileIds(page);
return ids.length > 0 ? ids[0] : null;
}
// ============================================================================
// CRUD Operations
// ============================================================================
/**
* Click the "New Profile" button in the header
*/
export async function clickNewProfileButton(page: Page): Promise<void> {
await clickElement(page, 'add-profile-button');
await waitForElement(page, 'add-profile-dialog');
}
/**
* Click the empty state card to create a new profile
*/
export async function clickEmptyState(page: Page): Promise<void> {
const emptyState = page.locator(
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
);
await emptyState.click();
await waitForElement(page, 'add-profile-dialog');
}
/**
* Fill the profile form with data
*/
export async function fillProfileForm(
page: Page,
data: {
name?: string;
description?: string;
icon?: string;
model?: string;
thinkingLevel?: string;
}
): Promise<void> {
if (data.name !== undefined) {
await fillProfileName(page, data.name);
}
if (data.description !== undefined) {
await fillProfileDescription(page, data.description);
}
if (data.icon !== undefined) {
await selectIcon(page, data.icon);
}
if (data.model !== undefined) {
await selectModel(page, data.model);
}
if (data.thinkingLevel !== undefined) {
await selectThinkingLevel(page, data.thinkingLevel);
}
}
/**
* Click the save button to create/update a profile
*/
export async function saveProfile(page: Page): Promise<void> {
await clickElement(page, 'save-profile-button');
// Wait for dialog to close
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
}
/**
* Click the cancel button in the profile dialog
*/
export async function cancelProfileDialog(page: Page): Promise<void> {
// Look for cancel button in dialog footer
const cancelButton = page.locator('button:has-text("Cancel")');
await cancelButton.click();
// Wait for dialog to close
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
}
/**
* Click the edit button for a specific profile
*/
export async function clickEditProfile(page: Page, profileId: string): Promise<void> {
await clickElement(page, `edit-profile-${profileId}`);
await waitForElement(page, 'edit-profile-dialog');
}
/**
* Click the delete button for a specific profile
*/
export async function clickDeleteProfile(page: Page, profileId: string): Promise<void> {
await clickElement(page, `delete-profile-${profileId}`);
await waitForElement(page, 'delete-profile-confirm-dialog');
}
/**
* Confirm profile deletion in the dialog
*/
export async function confirmDeleteProfile(page: Page): Promise<void> {
await clickElement(page, 'confirm-delete-profile-button');
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
}
/**
* Cancel profile deletion
*/
export async function cancelDeleteProfile(page: Page): Promise<void> {
await clickElement(page, 'cancel-delete-button');
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
}
// ============================================================================
// Form Field Operations
// ============================================================================
/**
* Fill the profile name field
*/
export async function fillProfileName(page: Page, name: string): Promise<void> {
await fillInput(page, 'profile-name-input', name);
}
/**
* Fill the profile description field
*/
export async function fillProfileDescription(page: Page, description: string): Promise<void> {
await fillInput(page, 'profile-description-input', description);
}
/**
* Select an icon for the profile
* @param iconName - Name of the icon: Brain, Zap, Scale, Cpu, Rocket, Sparkles
*/
export async function selectIcon(page: Page, iconName: string): Promise<void> {
await clickElement(page, `icon-select-${iconName}`);
}
/**
* Select a model for the profile
* @param modelId - Model ID: haiku, sonnet, opus
*/
export async function selectModel(page: Page, modelId: string): Promise<void> {
await clickElement(page, `model-select-${modelId}`);
}
/**
* Select a thinking level for the profile
* @param level - Thinking level: none, low, medium, high, ultrathink
*/
export async function selectThinkingLevel(page: Page, level: string): Promise<void> {
await clickElement(page, `thinking-select-${level}`);
}
/**
* Get the currently selected icon
*/
export async function getSelectedIcon(page: Page): Promise<string | null> {
// Find the icon button with primary background
const selectedIcon = page.locator('[data-testid^="icon-select-"][class*="bg-primary"]');
const testId = await selectedIcon.getAttribute('data-testid');
return testId ? testId.replace('icon-select-', '') : null;
}
/**
* Get the currently selected model
*/
export async function getSelectedModel(page: Page): Promise<string | null> {
// Find the model button with primary background
const selectedModel = page.locator('[data-testid^="model-select-"][class*="bg-primary"]');
const testId = await selectedModel.getAttribute('data-testid');
return testId ? testId.replace('model-select-', '') : null;
}
/**
* Get the currently selected thinking level
*/
export async function getSelectedThinkingLevel(page: Page): Promise<string | null> {
// Find the thinking level button with amber background
const selectedLevel = page.locator('[data-testid^="thinking-select-"][class*="bg-amber-500"]');
const testId = await selectedLevel.getAttribute('data-testid');
return testId ? testId.replace('thinking-select-', '') : null;
}
// ============================================================================
// Dialog Operations
// ============================================================================
/**
* Check if the add profile dialog is open
*/
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, 'add-profile-dialog');
return await dialog.isVisible().catch(() => false);
}
/**
* Check if the edit profile dialog is open
*/
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, 'edit-profile-dialog');
return await dialog.isVisible().catch(() => false);
}
/**
* Check if the delete confirmation dialog is open
*/
export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, 'delete-profile-confirm-dialog');
return await dialog.isVisible().catch(() => false);
}
/**
* Wait for any profile dialog to close
* This ensures all dialog animations complete before proceeding
*/
export async function waitForDialogClose(page: Page): Promise<void> {
// Wait for all profile dialogs to be hidden
await Promise.all([
waitForElementHidden(page, 'add-profile-dialog').catch(() => {}),
waitForElementHidden(page, 'edit-profile-dialog').catch(() => {}),
waitForElementHidden(page, 'delete-profile-confirm-dialog').catch(() => {}),
]);
// Also wait for any Radix dialog overlay to be removed (handles animation)
await page
.locator('[data-radix-dialog-overlay]')
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {
// Overlay may not exist
});
}
// ============================================================================
// Profile Card Inspection
// ============================================================================
/**
* Get the profile name from a card
*/
export async function getProfileName(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const nameElement = card.locator('h3');
return await nameElement.textContent().then((text) => text?.trim() || '');
}
/**
* Get the profile description from a card
*/
export async function getProfileDescription(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const descElement = card.locator('p').first();
return await descElement.textContent().then((text) => text?.trim() || '');
}
/**
* Get the profile model badge text from a card
*/
export async function getProfileModel(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const modelBadge = card.locator(
'span[class*="border-primary"]:has-text("haiku"), span[class*="border-primary"]:has-text("sonnet"), span[class*="border-primary"]:has-text("opus")'
);
return await modelBadge.textContent().then((text) => text?.trim() || '');
}
/**
* Get the profile thinking level badge text from a card
*/
export async function getProfileThinkingLevel(
page: Page,
profileId: string
): Promise<string | null> {
const card = await getProfileCard(page, profileId);
const thinkingBadge = card.locator('span[class*="border-amber-500"]');
const isVisible = await thinkingBadge.isVisible().catch(() => false);
if (!isVisible) return null;
return await thinkingBadge.textContent().then((text) => text?.trim() || '');
}
/**
* Check if a profile has the built-in badge
*/
export async function isBuiltInProfile(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
const builtInBadge = card.locator('span:has-text("Built-in")');
return await builtInBadge.isVisible().catch(() => false);
}
/**
* Check if the edit button is visible for a profile
*/
export async function isEditButtonVisible(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
// Hover over card to make buttons visible
await card.hover();
const editButton = await getByTestId(page, `edit-profile-${profileId}`);
// Wait for button to become visible after hover (handles CSS transition)
try {
await editButton.waitFor({ state: 'visible', timeout: 2000 });
return true;
} catch {
return false;
}
}
/**
* Check if the delete button is visible for a profile
*/
export async function isDeleteButtonVisible(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
// Hover over card to make buttons visible
await card.hover();
const deleteButton = await getByTestId(page, `delete-profile-${profileId}`);
// Wait for button to become visible after hover (handles CSS transition)
try {
await deleteButton.waitFor({ state: 'visible', timeout: 2000 });
return true;
} catch {
return false;
}
}
// ============================================================================
// Drag & Drop
// ============================================================================
/**
* Drag a profile from one position to another
* Uses the drag handle and dnd-kit library pattern
*
* Note: dnd-kit requires pointer events with specific timing for drag recognition.
* Manual mouse operations are needed because Playwright's dragTo doesn't work
* reliably with dnd-kit's pointer-based drag detection.
*
* @param fromIndex - 0-based index of the profile to drag
* @param toIndex - 0-based index of the target position
*/
export async function dragProfile(page: Page, fromIndex: number, toIndex: number): Promise<void> {
// Get all profile cards
const cards = await page.locator('[data-testid^="profile-card-"]').all();
if (fromIndex >= cards.length || toIndex >= cards.length) {
throw new Error(
`Invalid drag indices: fromIndex=${fromIndex}, toIndex=${toIndex}, total=${cards.length}`
);
}
const fromCard = cards[fromIndex];
const toCard = cards[toIndex];
// Get the drag handle within the source card
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
// Ensure drag handle is visible and ready
await dragHandle.waitFor({ state: 'visible', timeout: 5000 });
// Get bounding boxes
const handleBox = await dragHandle.boundingBox();
const toBox = await toCard.boundingBox();
if (!handleBox || !toBox) {
throw new Error('Unable to get bounding boxes for drag operation');
}
// Start position (center of drag handle)
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
// End position (center of target card)
const endX = toBox.x + toBox.width / 2;
const endY = toBox.y + toBox.height / 2;
// Perform manual drag operation
// dnd-kit needs pointer events in a specific sequence
await page.mouse.move(startX, startY);
await page.mouse.down();
// dnd-kit requires a brief hold before recognizing the drag gesture
// This is a library requirement, not an arbitrary timeout
await page.waitForTimeout(150);
// Move to target in steps for smoother drag recognition
await page.mouse.move(endX, endY, { steps: 10 });
// Brief pause before drop
await page.waitForTimeout(100);
await page.mouse.up();
// Wait for reorder animation to complete
await page.waitForTimeout(200);
}
/**
* Get the current order of all profile IDs
* Returns array of profile IDs in display order
*/
export async function getProfileOrder(page: Page): Promise<string[]> {
const cards = await page.locator('[data-testid^="profile-card-"]').all();
const ids: string[] = [];
for (const card of cards) {
const testId = await card.getAttribute('data-testid');
if (testId) {
// Extract profile ID from data-testid="profile-card-{id}"
const profileId = testId.replace('profile-card-', '');
ids.push(profileId);
}
}
return ids;
}
// ============================================================================
// Header Actions
// ============================================================================
/**
* Click the "Refresh Defaults" button
*/
export async function clickRefreshDefaults(page: Page): Promise<void> {
await clickElement(page, 'refresh-profiles-button');
}

View File

@@ -102,10 +102,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enableDependencyBlocking',
'skipVerificationInAutoMode',
'useWorktrees',
'showProfilesOnly',
'defaultPlanningMode',
'defaultRequirePlanApproval',
'defaultAIProfileId',
'muteDoneSound',
'enhancementModel',
'validationModel',
@@ -114,7 +112,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
'cursorDefaultModel',
'autoLoadClaudeMd',
'keyboardShortcuts',
'aiProfiles',
'mcpServers',
'promptCustomization',
'projects',
@@ -174,7 +171,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
When merging localStorage with server data:
1. **Server has data** → Use server data as base
2. **Server missing arrays** (projects, aiProfiles, etc.) → Use localStorage arrays
2. **Server missing arrays** (projects, mcpServers, etc.) → Use localStorage arrays
3. **Server missing objects** (lastSelectedSessionByProject) → Use localStorage objects
4. **Simple values** (lastProjectDir, currentProjectId) → Use localStorage if server is empty

View File

@@ -103,7 +103,6 @@ export type {
PhaseModelConfig,
PhaseModelKey,
KeyboardShortcuts,
AIProfile,
MCPToolInfo,
MCPServerConfig,
ProjectRef,
@@ -125,8 +124,6 @@ export {
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
THINKING_TOKEN_BUDGET,
profileHasThinking,
getProfileModelString,
getThinkingTokenBudget,
} from './settings.js';

View File

@@ -201,8 +201,6 @@ export interface KeyboardShortcuts {
context: string;
/** Open settings */
settings: string;
/** Open AI profiles */
profiles: string;
/** Open terminal */
terminal: string;
/** Toggle sidebar visibility */
@@ -223,8 +221,6 @@ export interface KeyboardShortcuts {
cyclePrevProject: string;
/** Cycle to next project */
cycleNextProject: string;
/** Add new AI profile */
addProfile: string;
/** Split terminal right */
splitTerminalRight: string;
/** Split terminal down */
@@ -233,96 +229,6 @@ export interface KeyboardShortcuts {
closeTerminal: string;
}
/**
* AIProfile - Configuration for an AI model with specific parameters
*
* Profiles can be built-in defaults or user-created. They define which model to use,
* thinking level, and other parameters for feature generation tasks.
*/
export interface AIProfile {
/** Unique identifier for the profile */
id: string;
/** Display name for the profile */
name: string;
/** User-friendly description */
description: string;
/** Provider selection: 'claude', 'cursor', or 'codex' */
provider: ModelProvider;
/** Whether this is a built-in default profile */
isBuiltIn: boolean;
/** Optional icon identifier or emoji */
icon?: string;
// Claude-specific settings
/** Which Claude model to use (opus, sonnet, haiku) - only for Claude provider */
model?: ModelAlias;
/** Extended thinking level for reasoning-based tasks - only for Claude provider */
thinkingLevel?: ThinkingLevel;
// Cursor-specific settings
/** Which Cursor model to use - only for Cursor provider
* Note: For Cursor, thinking is embedded in the model ID (e.g., 'claude-sonnet-4-thinking')
*/
cursorModel?: CursorModelId;
// Codex-specific settings
/** Which Codex/GPT model to use - only for Codex provider */
codexModel?: CodexModelId;
// OpenCode-specific settings
/** Which OpenCode model to use - only for OpenCode provider */
opencodeModel?: OpencodeModelId;
}
/**
* Helper to determine if a profile uses thinking mode
*/
export function profileHasThinking(profile: AIProfile): boolean {
if (profile.provider === 'claude') {
return profile.thinkingLevel !== undefined && profile.thinkingLevel !== 'none';
}
if (profile.provider === 'cursor') {
const model = profile.cursorModel || 'auto';
// Check using model map for hasThinking flag, or check for 'thinking' in name
const modelConfig = CURSOR_MODEL_MAP[model];
return modelConfig?.hasThinking ?? false;
}
if (profile.provider === 'codex') {
// Codex models handle thinking internally (o-series models)
const model = profile.codexModel || 'codex-gpt-5.2';
return model.startsWith('o');
}
if (profile.provider === 'opencode') {
// OpenCode models don't expose thinking configuration
return false;
}
return false;
}
/**
* Get effective model string for execution
*/
export function getProfileModelString(profile: AIProfile): string {
if (profile.provider === 'cursor') {
return `cursor:${profile.cursorModel || 'auto'}`;
}
if (profile.provider === 'codex') {
return `codex:${profile.codexModel || 'codex-gpt-5.2'}`;
}
if (profile.provider === 'opencode') {
return `opencode:${profile.opencodeModel || DEFAULT_OPENCODE_MODEL}`;
}
// Claude
return profile.model || 'sonnet';
}
/**
* MCPToolInfo - Information about a tool provided by an MCP server
*
@@ -426,7 +332,7 @@ export interface ChatSessionRef {
* GlobalSettings - User preferences and state stored globally in {DATA_DIR}/settings.json
*
* This is the main settings file that persists user preferences across sessions.
* Includes theme, UI state, feature defaults, keyboard shortcuts, AI profiles, and projects.
* Includes theme, UI state, feature defaults, keyboard shortcuts, and projects.
* Format: JSON with version field for migration support.
*/
export interface GlobalSettings {
@@ -468,14 +374,10 @@ export interface GlobalSettings {
skipVerificationInAutoMode: boolean;
/** Default: use git worktrees for feature branches */
useWorktrees: boolean;
/** Default: only show AI profiles (hide other settings) */
showProfilesOnly: boolean;
/** Default: planning approach (skip/lite/spec/full) */
defaultPlanningMode: PlanningMode;
/** Default: require manual approval before generating */
defaultRequirePlanApproval: boolean;
/** ID of currently selected AI profile (null = use built-in) */
defaultAIProfileId: string | null;
// Audio Preferences
/** Mute completion notification sound */
@@ -507,10 +409,6 @@ export interface GlobalSettings {
/** User's keyboard shortcut bindings */
keyboardShortcuts: KeyboardShortcuts;
// AI Profiles
/** User-created AI profiles */
aiProfiles: AIProfile[];
// Project Management
/** List of active projects */
projects: ProjectRef[];
@@ -754,7 +652,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
spec: 'D',
context: 'C',
settings: 'S',
profiles: 'M',
terminal: 'T',
toggleSidebar: '`',
addFeature: 'N',
@@ -765,7 +662,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
projectPicker: 'P',
cyclePrevProject: 'Q',
cycleNextProject: 'E',
addProfile: 'N',
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
@@ -786,10 +682,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
useWorktrees: true,
showProfilesOnly: false,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
muteDoneSound: false,
phaseModels: DEFAULT_PHASE_MODELS,
enhancementModel: 'sonnet',
@@ -799,7 +693,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [],
projects: [],
trashedProjects: [],
currentProjectId: null,