diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 5c93f4e2..92934722 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -9,11 +9,11 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; 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 { Checkbox } from '@/components/ui/checkbox'; import { CategoryAutocomplete } from '@/components/ui/category-autocomplete'; import { DescriptionImageDropZone, @@ -21,15 +21,10 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { - MessageSquare, - Settings2, - SlidersHorizontal, - Sparkles, - ChevronDown, - Play, -} from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { modelSupportsThinking } from '@/lib/utils'; import { @@ -41,19 +36,22 @@ import { PlanningMode, Feature, } from '@/store/app-store'; -import type { ReasoningEffort } from '@automaker/types'; -import { codexModelHasThinking, supportsReasoningEffort } from '@automaker/types'; +import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types'; +import { + supportsReasoningEffort, + PROVIDER_PREFIXES, + isCursorModel, + isClaudeModel, +} from '@automaker/types'; import { - ModelSelector, - ThinkingLevelSelector, - ReasoningEffortSelector, - ProfileQuickSelect, TestingTabContent, PrioritySelector, BranchSelector, - PlanningModeSelector, + PlanningModeSelect, AncestorContextSection, + ProfileTypeahead, } from '../shared'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import { DropdownMenu, @@ -67,7 +65,6 @@ import { formatAncestorContextForPrompt, type AncestorContext, } from '@automaker/dependency-resolver'; -import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types'; const logger = createLogger('AddFeatureDialog'); @@ -82,7 +79,7 @@ type FeatureData = { model: AgentModel; thinkingLevel: ThinkingLevel; reasoningEffort: ReasoningEffort; - branchName: string; // Can be empty string to use current branch + branchName: string; priority: number; planningMode: PlanningMode; requirePlanApproval: boolean; @@ -96,14 +93,13 @@ interface AddFeatureDialogProps { onAddAndStart?: (feature: FeatureData) => void; categorySuggestions: string[]; branchSuggestions: string[]; - branchCardCounts?: Record; // Map of branch name to unarchived card count + branchCardCounts?: Record; defaultSkipTests: boolean; defaultBranch?: string; currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; - // Spawn task mode props parentFeature?: Feature | null; allFeatures?: Feature[]; } @@ -128,37 +124,43 @@ export function AddFeatureDialog({ const isSpawnMode = !!parentFeature; const navigate = useNavigate(); const [useCurrentBranch, setUseCurrentBranch] = useState(true); - const [newFeature, setNewFeature] = useState({ - title: '', - category: '', - description: '', - images: [] as FeatureImage[], - imagePaths: [] as DescriptionImagePath[], - textFilePaths: [] as DescriptionTextFilePath[], - skipTests: false, - model: 'opus' as ModelAlias, - thinkingLevel: 'none' as ThinkingLevel, - reasoningEffort: 'none' as ReasoningEffort, - branchName: '', - priority: 2 as number, // Default to medium priority - }); - const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState( - () => new Map() - ); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + // Form state + const [title, setTitle] = useState(''); + const [category, setCategory] = useState(''); + const [description, setDescription] = useState(''); + const [images, setImages] = useState([]); + const [imagePaths, setImagePaths] = useState([]); + const [textFilePaths, setTextFilePaths] = useState([]); + const [skipTests, setSkipTests] = useState(false); + const [branchName, setBranchName] = useState(''); + const [priority, setPriority] = useState(2); + + // Model selection state + const [selectedProfileId, setSelectedProfileId] = useState(); + const [modelEntry, setModelEntry] = useState({ model: 'opus' }); + + // Check if current model supports planning mode (Claude/Anthropic only) + const modelSupportsPlanningMode = isClaudeModel(modelEntry.model); + + // Planning mode state + const [planningMode, setPlanningMode] = useState('skip'); + const [requirePlanApproval, setRequirePlanApproval] = useState(false); + + // UI state + const [previewMap, setPreviewMap] = useState(() => new Map()); const [descriptionError, setDescriptionError] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); const [enhancementMode, setEnhancementMode] = useState< 'improve' | 'technical' | 'simplify' | 'acceptance' >('improve'); - const [planningMode, setPlanningMode] = useState('skip'); - const [requirePlanApproval, setRequirePlanApproval] = useState(false); + const [enhanceOpen, setEnhanceOpen] = useState(false); // Spawn mode state const [ancestors, setAncestors] = useState([]); const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); - // Get planning mode defaults and worktrees setting from store + // Get defaults from store const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } = useAppStore(); @@ -168,28 +170,29 @@ export function AddFeatureDialog({ // Sync defaults when dialog opens useEffect(() => { if (open) { - // Find the default profile if one is set const defaultProfile = defaultAIProfileId ? aiProfiles.find((p) => p.id === defaultAIProfileId) : null; - setNewFeature((prev) => ({ - ...prev, - skipTests: defaultSkipTests, - branchName: defaultBranch || '', - // Use default profile's model/thinkingLevel if set, else fallback to defaults - model: defaultProfile?.model ?? 'opus', - thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', - })); + setSkipTests(defaultSkipTests); + setBranchName(defaultBranch || ''); setUseCurrentBranch(true); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); + // Set model from default profile or fallback + if (defaultProfile) { + setSelectedProfileId(defaultProfile.id); + applyProfileToModel(defaultProfile); + } else { + setSelectedProfileId(undefined); + setModelEntry({ model: 'opus' }); + } + // Initialize ancestors for spawn mode if (parentFeature) { const ancestorList = getAncestors(parentFeature, allFeatures); setAncestors(ancestorList); - // Only select parent by default - ancestors are optional context setSelectedAncestorIds(new Set([parentFeature.id])); } else { setAncestors([]); @@ -208,36 +211,62 @@ export function AddFeatureDialog({ 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 => { - if (!newFeature.description.trim()) { + if (!description.trim()) { setDescriptionError(true); return null; } - // Validate branch selection when "other branch" is selected - if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) { + if (useWorktrees && !useCurrentBranch && !branchName.trim()) { toast.error('Please select a branch name'); return null; } - const category = newFeature.category || 'Uncategorized'; - const selectedModel = newFeature.model; + const finalCategory = category || 'Uncategorized'; + const selectedModel = modelEntry.model; const normalizedThinking = modelSupportsThinking(selectedModel) - ? newFeature.thinkingLevel + ? modelEntry.thinkingLevel || 'none' : 'none'; const normalizedReasoning = supportsReasoningEffort(selectedModel) - ? newFeature.reasoningEffort + ? modelEntry.reasoningEffort || 'none' : 'none'; - // Use current branch if toggle is on - // If currentBranch is provided (non-primary worktree), use it - // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) - const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || ''; + const finalBranchName = useCurrentBranch ? currentBranch || '' : branchName || ''; - // Build final description - prepend ancestor context in spawn mode - let finalDescription = newFeature.description; + // Build final description with ancestor context in spawn mode + let finalDescription = description; if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) { - // Create parent context as an AncestorContext const parentContext: AncestorContext = { id: parentFeature.id, title: parentFeature.title, @@ -254,93 +283,84 @@ export function AddFeatureDialog({ ); if (contextText) { - finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`; + finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`; } } return { - title: newFeature.title, - category, + title, + category: finalCategory, description: finalDescription, - images: newFeature.images, - imagePaths: newFeature.imagePaths, - textFilePaths: newFeature.textFilePaths, - skipTests: newFeature.skipTests, + images, + imagePaths, + textFilePaths, + skipTests, model: selectedModel, thinkingLevel: normalizedThinking, reasoningEffort: normalizedReasoning, branchName: finalBranchName, - priority: newFeature.priority, + priority, planningMode, requirePlanApproval, - // In spawn mode, automatically add parent as dependency dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined, }; }; const resetForm = () => { - setNewFeature({ - title: '', - category: '', - description: '', - images: [], - imagePaths: [], - textFilePaths: [], - skipTests: defaultSkipTests, - model: 'opus', - priority: 2, - thinkingLevel: 'none', - reasoningEffort: 'none', - branchName: '', - }); + setTitle(''); + setCategory(''); + setDescription(''); + setImages([]); + setImagePaths([]); + setTextFilePaths([]); + setSkipTests(defaultSkipTests); + setBranchName(''); + setPriority(2); + setSelectedProfileId(undefined); + setModelEntry({ model: 'opus' }); setUseCurrentBranch(true); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); + setPreviewMap(new Map()); setDescriptionError(false); + setEnhanceOpen(false); onOpenChange(false); }; const handleAction = (actionFn?: (data: FeatureData) => void) => { if (!actionFn) return; - const featureData = buildFeatureData(); if (!featureData) return; - actionFn(featureData); resetForm(); }; const handleAdd = () => handleAction(onAdd); - const handleAddAndStart = () => handleAction(onAddAndStart); const handleDialogClose = (open: boolean) => { onOpenChange(open); if (!open) { - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); + setPreviewMap(new Map()); setDescriptionError(false); } }; const handleEnhanceDescription = async () => { - if (!newFeature.description.trim() || isEnhancing) return; + if (!description.trim() || isEnhancing) return; setIsEnhancing(true); try { const api = getElectronAPI(); const result = await api.enhancePrompt?.enhance( - newFeature.description, + description, enhancementMode, - enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry - enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level + enhancementOverride.effectiveModel, + enhancementOverride.effectiveModelEntry.thinkingLevel ); if (result?.success && result.enhancedText) { - const enhancedText = result.enhancedText; - setNewFeature((prev) => ({ ...prev, description: enhancedText })); + setDescription(result.enhancedText); toast.success('Description enhanced!'); } else { toast.error(result?.error || 'Failed to enhance description'); @@ -353,59 +373,9 @@ export function AddFeatureDialog({ } }; - const handleModelSelect = (model: string) => { - // For Cursor models, thinking is handled by the model itself - // For Claude models, check if it supports extended thinking - const isCursor = isCursorModel(model); - setNewFeature({ - ...newFeature, - model: model as ModelAlias, - thinkingLevel: isCursor - ? 'none' - : modelSupportsThinking(model) - ? newFeature.thinkingLevel - : 'none', - }); - }; - - const handleProfileSelect = (profile: AIProfile) => { - if (profile.provider === 'cursor') { - // Cursor profile - set cursor model - const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - setNewFeature({ - ...newFeature, - model: cursorModel as ModelAlias, - thinkingLevel: 'none', // Cursor handles thinking internally - }); - } else { - // Claude profile - ensure model is always set from profile - const profileModel = profile.model; - if (!profileModel || !['haiku', 'sonnet', 'opus'].includes(profileModel)) { - console.warn( - `[ProfileSelect] Invalid or missing model "${profileModel}" for profile "${profile.name}", defaulting to sonnet` - ); - } - setNewFeature({ - ...newFeature, - model: - profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel) - ? profileModel - : 'sonnet', - thinkingLevel: - profile.thinkingLevel && profile.thinkingLevel !== 'none' - ? profile.thinkingLevel - : 'none', - }); - } - }; - - // Cursor models handle thinking internally, so only show thinking selector for Claude models - const isCurrentModelCursor = isCursorModel(newFeature.model); - const newModelAllowsThinking = - !isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet'); - - // Codex models that support reasoning effort - show reasoning selector - const newModelAllowsReasoning = supportsReasoningEffort(newFeature.model || ''); + // Shared card styling + const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3'; + const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground'; return ( @@ -433,239 +403,264 @@ export function AddFeatureDialog({ : 'Create a new feature card for the Kanban board.'} - - - - - Prompt - - - - Model - - - - Options - - - {/* Prompt Tab */} - - {/* Ancestor Context Section - only in spawn mode */} - {isSpawnMode && parentFeature && ( - - )} +
+ {/* Ancestor Context Section - only in spawn mode */} + {isSpawnMode && parentFeature && ( + + )} + {/* Task Details Section */} +
{ - setNewFeature({ ...newFeature, description: value }); - if (value.trim()) { - setDescriptionError(false); - } + setDescription(value); + if (value.trim()) setDescriptionError(false); }} - images={newFeature.imagePaths} - onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })} - textFiles={newFeature.textFilePaths} - onTextFilesChange={(textFiles) => - setNewFeature({ ...newFeature, textFilePaths: textFiles }) - } + images={imagePaths} + onImagesChange={setImagePaths} + textFiles={textFilePaths} + onTextFilesChange={setTextFilePaths} placeholder="Describe the feature..." - previewMap={newFeaturePreviewMap} - onPreviewMapChange={setNewFeaturePreviewMap} + previewMap={previewMap} + onPreviewMapChange={setPreviewMap} autoFocus error={descriptionError} />
+
setNewFeature({ ...newFeature, title: e.target.value })} + value={title} + onChange={(e) => setTitle(e.target.value)} placeholder="Leave blank to auto-generate" />
-
- - - + + +
+ + + + + + setEnhancementMode('improve')}> + Improve Clarity + + setEnhancementMode('technical')}> + Add Technical Details + + setEnhancementMode('simplify')}> + Simplify + + setEnhancementMode('acceptance')}> + Add Acceptance Criteria + + + + + - - - setEnhancementMode('improve')}> - Improve Clarity - - setEnhancementMode('technical')}> - Add Technical Details - - setEnhancementMode('simplify')}> - Simplify - - setEnhancementMode('acceptance')}> - Add Acceptance Criteria - - - - - - -
-
- - setNewFeature({ ...newFeature, category: value })} - suggestions={categorySuggestions} - placeholder="e.g., Core, UI, API" - data-testid="feature-category-input" - /> -
- {useWorktrees && ( - setNewFeature({ ...newFeature, branchName: value })} - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentBranch} - testIdPrefix="feature" - /> - )} - - {/* Priority Selector */} - setNewFeature({ ...newFeature, priority })} - testIdPrefix="priority" - /> - - - {/* Model Tab */} - - {/* Show Advanced Options Toggle */} - {showProfilesOnly && ( -
-
-

Simple Mode Active

-

- Only showing AI profiles. Advanced model tweaking is hidden. -

+
- + + +
+ + {/* AI & Execution Section */} +
+
+ + AI & Execution +
+ +
+
+ + { + onOpenChange(false); + navigate({ to: '/profiles' }); + }} + testIdPrefix="add-feature-profile" + /> +
+
+ + +
+
+ +
+ {modelSupportsPlanningMode && ( +
+ + +
+ )} +
+ +
+
+ setSkipTests(!checked)} + data-testid="add-feature-skip-tests-checkbox" + /> + +
+ {modelSupportsPlanningMode && ( +
+ setRequirePlanApproval(!!checked)} + disabled={planningMode === 'skip' || planningMode === 'lite'} + data-testid="add-feature-require-approval-checkbox" + /> + +
+ )} +
+
+
+
+ + {/* Organization Section */} +
+
+ + Organization +
+ +
+
+ + +
+
+ + +
+
+ + {/* Branch Selector */} + {useWorktrees && ( +
+
)} +
+
- {/* Quick Select Profile Section */} - { - onOpenChange(false); - navigate({ to: '/profiles' }); - }} - /> - - {/* Separator */} - {aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && ( -
- )} - - {/* Claude Models Section */} - {(!showProfilesOnly || showAdvancedOptions) && ( - <> - - {newModelAllowsThinking && ( - - setNewFeature({ ...newFeature, thinkingLevel: level }) - } - /> - )} - {newModelAllowsReasoning && ( - - setNewFeature({ ...newFeature, reasoningEffort: effort }) - } - /> - )} - - )} - - - {/* Options Tab */} - - {/* Planning Mode Section */} - - -
- - {/* Testing Section */} - setNewFeature({ ...newFeature, skipTests })} - /> - - + + +
+

Version History

+

+ Click a version to restore it +

+
+
+ {[...(feature.descriptionHistory || [])] + .reverse() + .map((entry: DescriptionHistoryEntry, index: number) => { + const isCurrentVersion = + entry.description === editingFeature.description; + const date = new Date(entry.timestamp); + const formattedDate = date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const sourceLabel = + entry.source === 'initial' + ? 'Original' + : entry.source === 'enhance' + ? `Enhanced (${entry.enhancementMode || 'improve'})` + : 'Edited'; + + return ( + + ); + })} +
+
+ + )} +
{ @@ -402,6 +469,7 @@ export function EditFeatureDialog({ data-testid="edit-feature-description" />
+
-
- - - + + +
+ + + + + + setEnhancementMode('improve')}> + Improve Clarity + + setEnhancementMode('technical')}> + Add Technical Details + + setEnhancementMode('simplify')}> + Simplify + + setEnhancementMode('acceptance')}> + Add Acceptance Criteria + + + + + - - - setEnhancementMode('improve')}> - Improve Clarity - - setEnhancementMode('technical')}> - Add Technical Details - - setEnhancementMode('simplify')}> - Simplify - - setEnhancementMode('acceptance')}> - Add Acceptance Criteria - - - - - - - - {/* Version History Button */} - {feature?.descriptionHistory && feature.descriptionHistory.length > 0 && ( - - - - - -
-

Version History

-

- Click a version to restore it -

-
-
- {[...(feature.descriptionHistory || [])] - .reverse() - .map((entry: DescriptionHistoryEntry, index: number) => { - const isCurrentVersion = entry.description === editingFeature.description; - const date = new Date(entry.timestamp); - const formattedDate = date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - const sourceLabel = - entry.source === 'initial' - ? 'Original' - : entry.source === 'enhance' - ? `Enhanced (${entry.enhancementMode || 'improve'})` - : 'Edited'; - - return ( - - ); - })} -
-
-
- )} -
-
- - - setEditingFeature({ - ...editingFeature, - category: value, - }) - } - suggestions={categorySuggestions} - placeholder="e.g., Core, UI, API" - data-testid="edit-feature-category" - /> -
- {useWorktrees && ( - - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentBranch} - disabled={editingFeature.status !== 'backlog'} - testIdPrefix="edit-feature" - /> - )} - - {/* Priority Selector */} - - setEditingFeature({ - ...editingFeature, - priority, - }) - } - testIdPrefix="edit-priority" - /> - - - {/* Model Tab */} - - {/* Show Advanced Options Toggle */} - {showProfilesOnly && ( -
-
-

Simple Mode Active

-

- Only showing AI profiles. Advanced model tweaking is hidden. -

+
- + + +
+ + {/* AI & Execution Section */} +
+
+ + AI & Execution +
+ +
+
+ + { + onClose(); + navigate({ to: '/profiles' }); + }} + testIdPrefix="edit-feature-profile" + /> +
+
+ + +
+
+ +
+ {modelSupportsPlanningMode && ( +
+ + +
+ )} +
+ +
+
+ + setEditingFeature({ ...editingFeature, skipTests: !checked }) + } + data-testid="edit-feature-skip-tests-checkbox" + /> + +
+ {modelSupportsPlanningMode && ( +
+ setRequirePlanApproval(!!checked)} + disabled={planningMode === 'skip' || planningMode === 'lite'} + data-testid="edit-feature-require-approval-checkbox" + /> + +
+ )} +
+
+
+
+ + {/* Organization Section */} +
+
+ + Organization +
+ +
+
+ + + setEditingFeature({ + ...editingFeature, + category: value, + }) + } + suggestions={categorySuggestions} + placeholder="e.g., Core, UI, API" + data-testid="edit-feature-category" + /> +
+
+ + + setEditingFeature({ + ...editingFeature, + priority, + }) + } + testIdPrefix="edit-priority" + /> +
+
+ + {/* Branch Selector */} + {useWorktrees && ( +
+ + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} + currentBranch={currentBranch} + disabled={editingFeature.status !== 'backlog'} + testIdPrefix="edit-feature" + />
)} +
+
- {/* Quick Select Profile Section */} - - - {/* Separator */} - {aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && ( -
- )} - - {/* Claude Models Section */} - {(!showProfilesOnly || showEditAdvancedOptions) && ( - <> - - {editModelAllowsThinking && ( - - setEditingFeature({ - ...editingFeature, - thinkingLevel: level, - }) - } - testIdPrefix="edit-thinking-level" - /> - )} - {editModelAllowsReasoning && ( - - setEditingFeature({ - ...editingFeature, - reasoningEffort: effort, - }) - } - testIdPrefix="edit-reasoning-effort" - /> - )} - - )} - - - {/* Options Tab */} - - {/* Planning Mode Section */} - - -
- - {/* Testing Section */} - setEditingFeature({ ...editingFeature, skipTests })} - testIdPrefix="edit" - /> - - - - -
+
+ + +
); } diff --git a/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx b/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx new file mode 100644 index 00000000..4080676c --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx @@ -0,0 +1,237 @@ +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(0); + const triggerRef = React.useRef(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 ( + + + + + + + + + No profile found. + + {filteredProfiles.map((profile) => { + const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider]; + const isSelected = profile.id === selectedProfileId; + const modelDisplay = getProfileModelDisplay(profile); + const thinkingDisplay = getProfileThinkingDisplay(profile); + + return ( + handleSelect(profile)} + className="flex items-center gap-2 py-2" + data-testid={`${testIdPrefix}-option-${profile.id}`} + > +
+ {ProviderIcon ? ( + + ) : ( + + )} +
+ {profile.name} + + {modelDisplay} + {thinkingDisplay && ( + + {thinkingDisplay} + )} + +
+
+
+ {profile.isBuiltIn && ( + + Built-in + + )} + +
+
+ ); + })} +
+ {showManageLink && onManageLinkClick && ( + <> + + + { + setOpen(false); + onManageLinkClick(); + }} + className="text-muted-foreground" + data-testid={`${testIdPrefix}-manage-link`} + > + + Manage AI Profiles + + + + )} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx index 5983a43f..1e7090d8 100644 --- a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx +++ b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx @@ -8,7 +8,7 @@ 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 } from '@/components/ui/provider-icon'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import { toast } from 'sonner'; import type { AIProfile, @@ -17,8 +17,15 @@ import type { ModelProvider, CursorModelId, CodexModelId, + OpencodeModelId, +} from '@automaker/types'; +import { + CURSOR_MODEL_MAP, + cursorModelHasThinking, + CODEX_MODEL_MAP, + OPENCODE_MODELS, + DEFAULT_OPENCODE_MODEL, } from '@automaker/types'; -import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; @@ -50,6 +57,8 @@ export function ProfileForm({ 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', }); @@ -66,6 +75,8 @@ export function ProfileForm({ 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]); @@ -79,10 +90,14 @@ export function ProfileForm({ // 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 models when switching to that provider + // 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, }); }; @@ -107,6 +122,13 @@ export function ProfileForm({ }); }; + const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => { + setFormData({ + ...formData, + opencodeModel, + }); + }; + const handleSubmit = () => { if (!formData.name.trim()) { toast.error('Please enter a profile name'); @@ -140,6 +162,11 @@ export function ProfileForm({ ...baseProfile, codexModel: formData.codexModel, }); + } else if (formData.provider === 'opencode') { + onSave({ + ...baseProfile, + opencodeModel: formData.opencodeModel, + }); } else { onSave({ ...baseProfile, @@ -203,7 +230,7 @@ export function ProfileForm({ {/* Provider Selection */}
-
+
+
@@ -404,6 +445,61 @@ export function ProfileForm({
)} + {/* OpenCode Model Selection */} + {formData.provider === 'opencode' && ( +
+ +
+ {OPENCODE_MODELS.map((model) => ( + + ))} +
+
+ )} + {/* Claude Thinking Level */} {formData.provider === 'claude' && supportsThinking && (
diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index e6b9c9ce..89387530 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -5,6 +5,7 @@ import type { ModelAlias, CursorModelId, CodexModelId, + OpencodeModelId, GroupedModel, PhaseModelEntry, ThinkingLevel, @@ -23,13 +24,14 @@ import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, + OPENCODE_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, } from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; import { Command, @@ -199,6 +201,10 @@ export function PhaseModelSelector({ const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + // Check OpenCode models + const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); + if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon }; + return null; }, [selectedModel, selectedThinkingLevel, availableCursorModels]); @@ -236,11 +242,12 @@ export function PhaseModelSelector({ }, [availableCursorModels, enabledCursorModels]); // Group models - const { favorites, claude, cursor, codex } = React.useMemo(() => { + const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof CODEX_MODELS = []; + const ocModels: typeof OPENCODE_MODELS = []; // Process Claude Models CLAUDE_MODELS.forEach((model) => { @@ -269,7 +276,22 @@ export function PhaseModelSelector({ } }); - return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels }; + // Process OpenCode Models + OPENCODE_MODELS.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + ocModels.push(model); + } + }); + + return { + favorites: favs, + claude: cModels, + cursor: curModels, + codex: codModels, + opencode: ocModels, + }; }, [favoriteModels, availableCursorModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) @@ -453,6 +475,64 @@ export function PhaseModelSelector({ ); }; + // Render OpenCode model item (simple selector, no thinking/reasoning options) + const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as OpencodeModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ {model.badge && ( + + {model.badge} + + )} + + {isSelected && } +
+
+ ); + }; + // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { const modelValue = stripProviderPrefix(model.id); @@ -835,6 +915,10 @@ export function PhaseModelSelector({ if (model.provider === 'codex') { return renderCodexModelItem(model); } + // OpenCode model + if (model.provider === 'opencode') { + return renderOpencodeModelItem(model); + } // Claude model return renderClaudeModelItem(model); }); @@ -864,6 +948,12 @@ export function PhaseModelSelector({ {codex.map((model) => renderCodexModelItem(model))} )} + + {opencode.length > 0 && ( + + {opencode.map((model) => renderOpencodeModelItem(model))} + + )}