mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge pull request #394 from AutoMaker-Org/remove-profiles
refactor: remove AI profile functionality and related components
This commit is contained in:
@@ -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[]) || [],
|
||||
|
||||
@@ -13,7 +13,6 @@ export type {
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
ChatSessionRef,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { SortableProfileCard } from './sortable-profile-card';
|
||||
export { ProfileForm } from './profile-form';
|
||||
export { ProfilesHeader } from './profiles-header';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ProfilesView } from '@/components/views/profiles-view';
|
||||
|
||||
export const Route = createFileRoute('/profiles')({
|
||||
component: ProfilesView,
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user