merge: resolve conflicts with upstream OpenCode support

- Combined CLI disconnection markers with OpenCode support
- Added OpenCode auth/deauth routes and API methods
- Resolved merge conflicts between feature branch and upstream v0.9.0rc
This commit is contained in:
DhanushSantosh
2026-01-09 22:25:46 +05:30
45 changed files with 6975 additions and 958 deletions

View File

@@ -1,4 +1,5 @@
import type { ComponentType, SVGProps } from 'react';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AgentModel, ModelProvider } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -96,6 +97,10 @@ export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
}
export function OpenCodeIcon({ className, ...props }: { className?: string }) {
return <Cpu className={cn('inline-block', className)} {...props} />;
}
export const PROVIDER_ICON_COMPONENTS: Record<
ModelProvider,
ComponentType<{ className?: string }>
@@ -103,6 +108,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
claude: AnthropicIcon,
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
codex: OpenAIIcon,
opencode: OpenCodeIcon,
};
/**

View File

@@ -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<string, number>; // Map of branch name to unarchived card count
branchCardCounts?: Record<string, number>;
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<ImagePreviewMap>(
() => 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<FeatureImage[]>([]);
const [imagePaths, setImagePaths] = useState<DescriptionImagePath[]>([]);
const [textFilePaths, setTextFilePaths] = useState<DescriptionTextFilePath[]>([]);
const [skipTests, setSkipTests] = useState(false);
const [branchName, setBranchName] = useState('');
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)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Planning mode state
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => 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<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={handleDialogClose}>
@@ -433,239 +403,264 @@ export function AddFeatureDialog({
: 'Create a new feature card for the Kanban board.'}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
Prompt
</TabsTrigger>
<TabsTrigger value="model" data-testid="tab-model">
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="options" data-testid="tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
value={newFeature.description}
value={description}
onChange={(value) => {
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}
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
value={newFeature.title}
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Leave blank to auto-generate"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-[200px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!newFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
<CategoryAutocomplete
value={newFeature.category}
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={newFeature.branchName}
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
)}
{/* Priority Selector */}
<PrioritySelector
selectedPriority={newFeature.priority}
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
testIdPrefix="priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
data-testid="show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</CollapsibleContent>
</Collapsible>
</div>
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<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>
<div
className={cn(
'grid gap-3',
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
)}
>
{modelSupportsPlanningMode && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="add-feature-planning"
compact
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-skip-tests"
checked={!skipTests}
onCheckedChange={(checked) => setSkipTests(!checked)}
data-testid="add-feature-skip-tests-checkbox"
/>
<Label
htmlFor="add-feature-skip-tests"
className="text-xs font-normal cursor-pointer"
>
Run tests
</Label>
</div>
{modelSupportsPlanningMode && (
<div className="flex items-center gap-2">
<Checkbox
id="add-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
data-testid="add-feature-require-approval-checkbox"
/>
<Label
htmlFor="add-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
)}
</div>
</div>
</div>
</div>
{/* Organization Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<FolderKanban className="w-4 h-4 text-muted-foreground" />
<span>Organization</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Category</Label>
<CategoryAutocomplete
value={category}
onChange={setCategory}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="feature-category-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Priority</Label>
<PrioritySelector
selectedPriority={priority}
onPrioritySelect={setPriority}
testIdPrefix="priority"
/>
</div>
</div>
{/* Branch Selector */}
{useWorktrees && (
<div className="pt-2">
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
</div>
)}
</div>
</div>
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={newFeature.model}
selectedThinkingLevel={newFeature.thinkingLevel}
selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined}
onSelect={handleProfileSelect}
showManageLink
onManageLinkClick={() => {
onOpenChange(false);
navigate({ to: '/profiles' });
}}
/>
{/* Separator */}
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showAdvancedOptions) && (
<>
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
{newModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={newFeature.thinkingLevel}
onLevelSelect={(level) =>
setNewFeature({ ...newFeature, thinkingLevel: level })
}
/>
)}
{newModelAllowsReasoning && (
<ReasoningEffortSelector
selectedEffort={newFeature.reasoningEffort}
onEffortSelect={(effort) =>
setNewFeature({ ...newFeature, reasoningEffort: effort })
}
/>
)}
</>
)}
</TabsContent>
{/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={newFeature.description}
testIdPrefix="add-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={newFeature.skipTests}
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
@@ -675,7 +670,7 @@ export function AddFeatureDialog({
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
@@ -686,7 +681,7 @@ export function AddFeatureDialog({
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-add-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
>
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
</HotkeyButton>

View File

@@ -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,18 +21,19 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
Sparkles,
ChevronDown,
ChevronRight,
GitBranch,
History,
Cpu,
FolderKanban,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import { cn, modelSupportsThinking } from '@/lib/utils';
import {
Feature,
ModelAlias,
@@ -41,17 +42,15 @@ import {
useAppStore,
PlanningMode,
} from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import {
ModelSelector,
ThinkingLevelSelector,
ReasoningEffortSelector,
ProfileQuickSelect,
TestingTabContent,
PrioritySelector,
BranchSelector,
PlanningModeSelector,
PlanningModeSelect,
ProfileTypeahead,
} from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
@@ -60,9 +59,14 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { DescriptionHistoryEntry } from '@automaker/types';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES, supportsReasoningEffort } from '@automaker/types';
import {
isCursorModel,
isClaudeModel,
PROVIDER_PREFIXES,
supportsReasoningEffort,
} from '@automaker/types';
import { useNavigate } from '@tanstack/react-router';
const logger = createLogger('EditFeatureDialog');
@@ -112,6 +116,7 @@ export function EditFeatureDialog({
aiProfiles,
allFeatures,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
// If feature has no branchName, default to using current branch
@@ -120,16 +125,28 @@ export function EditFeatureDialog({
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Model selection state
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
thinkingLevel: feature?.thinkingLevel || 'none',
reasoningEffort: feature?.reasoningEffort || 'none',
}));
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
@@ -156,14 +173,52 @@ export function EditFeatureDialog({
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setShowHistory(false);
setEnhanceOpen(false);
// Reset model entry
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
setSelectedProfileId(undefined);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
setDescriptionChangeSource(null);
setShowHistory(false);
}
}, [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 = () => {
if (!editingFeature) return;
@@ -179,12 +234,12 @@ export function EditFeatureDialog({
return;
}
const selectedModel = (editingFeature.model ?? 'opus') as ModelAlias;
const selectedModel = modelEntry.model;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
? (modelEntry.thinkingLevel ?? 'none')
: 'none';
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
? (editingFeature.reasoningEffort ?? 'none')
? (modelEntry.reasoningEffort ?? 'none')
: 'none';
// Use current branch if toggle is on
@@ -226,7 +281,6 @@ export function EditFeatureDialog({
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
onClose();
};
@@ -236,56 +290,6 @@ export function EditFeatureDialog({
}
};
const handleModelSelect = (model: string) => {
if (!editingFeature) return;
// For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking
// For Codex models, use reasoning effort instead
const isCursor = isCursorModel(model);
const supportsThinking = modelSupportsThinking(model);
const supportsReasoning = supportsReasoningEffort(model);
setEditingFeature({
...editingFeature,
model: model as ModelAlias,
thinkingLevel:
isCursor || !supportsThinking ? 'none' : (editingFeature.thinkingLevel ?? 'none'),
reasoningEffort: !supportsReasoning ? 'none' : (editingFeature.reasoningEffort ?? 'none'),
});
};
const handleProfileSelect = (profile: AIProfile) => {
if (!editingFeature) return;
if (profile.provider === 'cursor') {
// Cursor profile - set cursor model
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setEditingFeature({
...editingFeature,
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`
);
}
setEditingFeature({
...editingFeature,
model:
profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel)
? profileModel
: 'sonnet',
thinkingLevel:
profile.thinkingLevel && profile.thinkingLevel !== 'none'
? profile.thinkingLevel
: 'none',
});
}
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
@@ -316,18 +320,14 @@ export function EditFeatureDialog({
}
};
// Cursor models handle thinking internally, so only show thinking selector for Claude models
const isCurrentModelCursor = isCursorModel(editingFeature?.model as string);
const editModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(editingFeature?.model);
// Codex models that support reasoning effort - show reasoning selector
const editModelAllowsReasoning = supportsReasoningEffort(editingFeature?.model || '');
if (!editingFeature) {
return null;
}
// 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 (
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
<DialogContent
@@ -350,26 +350,93 @@ export function EditFeatureDialog({
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
Prompt
</TabsTrigger>
<TabsTrigger value="model" data-testid="edit-tab-model">
<Settings2 className="w-4 h-4 mr-2" />
Model
</TabsTrigger>
<TabsTrigger value="options" data-testid="edit-tab-options">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Options
</TabsTrigger>
</TabsList>
{/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(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 (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(value) => {
@@ -402,6 +469,7 @@ export function EditFeatureDialog({
data-testid="edit-feature-description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
@@ -417,274 +485,233 @@ export function EditFeatureDialog({
data-testid="edit-feature-title"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-[180px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<History className="w-4 h-4" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(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 (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
<CategoryAutocomplete
value={editingFeature.category}
onChange={(value) =>
setEditingFeature({
...editingFeature,
category: value,
})
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="edit-feature-category"
/>
</div>
{useWorktrees && (
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
{/* Priority Selector */}
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</TabsContent>
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</CollapsibleContent>
</Collapsible>
</div>
{/* AI & Execution Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<Cpu className="w-4 h-4 text-muted-foreground" />
<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>
<div
className={cn(
'grid gap-3',
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
)}
>
{modelSupportsPlanningMode && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Planning</Label>
<PlanningModeSelect
mode={planningMode}
onModeChange={setPlanningMode}
testIdPrefix="edit-feature-planning"
compact
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Options</Label>
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-skip-tests"
checked={!(editingFeature.skipTests ?? false)}
onCheckedChange={(checked) =>
setEditingFeature({ ...editingFeature, skipTests: !checked })
}
data-testid="edit-feature-skip-tests-checkbox"
/>
<Label
htmlFor="edit-feature-skip-tests"
className="text-xs font-normal cursor-pointer"
>
Run tests
</Label>
</div>
{modelSupportsPlanningMode && (
<div className="flex items-center gap-2">
<Checkbox
id="edit-feature-require-approval"
checked={requirePlanApproval}
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
disabled={planningMode === 'skip' || planningMode === 'lite'}
data-testid="edit-feature-require-approval-checkbox"
/>
<Label
htmlFor="edit-feature-require-approval"
className={cn(
'text-xs font-normal',
planningMode === 'skip' || planningMode === 'lite'
? 'cursor-not-allowed text-muted-foreground'
: 'cursor-pointer'
)}
>
Require approval
</Label>
</div>
)}
</div>
</div>
</div>
</div>
{/* Organization Section */}
<div className={cardClass}>
<div className={sectionHeaderClass}>
<FolderKanban className="w-4 h-4 text-muted-foreground" />
<span>Organization</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Category</Label>
<CategoryAutocomplete
value={editingFeature.category}
onChange={(value) =>
setEditingFeature({
...editingFeature,
category: value,
})
}
suggestions={categorySuggestions}
placeholder="e.g., Core, UI, API"
data-testid="edit-feature-category"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Priority</Label>
<PrioritySelector
selectedPriority={editingFeature.priority ?? 2}
onPrioritySelect={(priority) =>
setEditingFeature({
...editingFeature,
priority,
})
}
testIdPrefix="edit-priority"
/>
</div>
</div>
{/* Branch Selector */}
{useWorktrees && (
<div className="pt-2">
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
</div>
)}
</div>
</div>
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
selectedCursorModel={
isCurrentModelCursor ? (editingFeature.model as string) : undefined
}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
{/* Separator */}
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? 'opus') as ModelAlias}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
thinkingLevel: level,
})
}
testIdPrefix="edit-thinking-level"
/>
)}
{editModelAllowsReasoning && (
<ReasoningEffortSelector
selectedEffort={editingFeature.reasoningEffort ?? 'none'}
onEffortSelect={(effort) =>
setEditingFeature({
...editingFeature,
reasoningEffort: effort,
})
}
testIdPrefix="edit-reasoning-effort"
/>
)}
</>
)}
</TabsContent>
{/* Options Tab */}
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
{/* Planning Mode Section */}
<PlanningModeSelector
mode={planningMode}
onModeChange={setPlanningMode}
requireApproval={requirePlanApproval}
onRequireApprovalChange={setRequirePlanApproval}
featureDescription={editingFeature.description}
testIdPrefix="edit-feature"
compact
/>
<div className="border-t border-border my-4" />
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
testIdPrefix="edit"
/>
</TabsContent>
</Tabs>
<DialogFooter className="sm:!justify-between">
<Button
variant="outline"

View File

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

View File

@@ -1,8 +1,12 @@
import type { ModelAlias } from '@/store/app-store';
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP } from '@automaker/types';
import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
@@ -99,9 +103,25 @@ export const CODEX_MODELS: ModelOption[] = [
];
/**
* All available models (Claude + Cursor + Codex)
* OpenCode models derived from OPENCODE_MODEL_CONFIGS
*/
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS];
export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config) => ({
id: config.id,
label: config.label,
description: config.description,
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
provider: 'opencode' as ModelProvider,
}));
/**
* All available models (Claude + Cursor + Codex + OpenCode)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -146,4 +166,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Anthropic: AnthropicIcon,
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
};

View File

@@ -19,6 +19,8 @@ interface PlanningModeSelectProps {
testIdPrefix?: string;
className?: string;
disabled?: boolean;
/** If true, only renders the dropdown without description or checkbox */
compact?: boolean;
}
const modes = [
@@ -81,47 +83,58 @@ export function PlanningModeSelect({
testIdPrefix = 'planning-mode',
className,
disabled = false,
compact = false,
}: PlanningModeSelectProps) {
const selectedMode = modes.find((m) => m.value === mode);
// Disable approval checkbox for skip/lite modes since they don't use planning
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
const selectDropdown = (
<Select
value={mode}
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedMode && (
<div className="flex items-center gap-2">
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
<span>{selectedMode.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{modes.map((m) => {
const Icon = m.icon;
return (
<SelectItem
key={m.value}
value={m.value}
data-testid={`${testIdPrefix}-option-${m.value}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', m.color)} />
<span>{m.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
// Compact mode - just the dropdown
if (compact) {
return <div className={className}>{selectDropdown}</div>;
}
// Full mode with description and optional checkbox
return (
<div className={cn('space-y-2', className)}>
<Select
value={mode}
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
disabled={disabled}
>
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
<SelectValue>
{selectedMode && (
<div className="flex items-center gap-2">
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
<span>{selectedMode.label}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{modes.map((m) => {
const Icon = m.icon;
return (
<SelectItem
key={m.value}
value={m.value}
data-testid={`${testIdPrefix}-option-${m.value}`}
>
<div className="flex items-center gap-2">
<Icon className={cn('h-3.5 w-3.5', m.color)} />
<span>{m.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{selectDropdown}
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
{onRequireApprovalChange && (
<div className="flex items-center gap-2 pt-1">

View File

@@ -1,4 +1,3 @@
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface PrioritySelectorProps {
@@ -13,49 +12,46 @@ export function PrioritySelector({
testIdPrefix = 'priority',
}: PrioritySelectorProps) {
return (
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 1
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-high-button`}
>
High
</button>
<button
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 2
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-medium-button`}
>
Medium
</button>
<button
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 3
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-low-button`}
>
Low
</button>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 1
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-high-button`}
>
High
</button>
<button
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 2
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-medium-button`}
>
Medium
</button>
<button
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
selectedPriority === 3
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
)}
data-testid={`${testIdPrefix}-low-button`}
>
Low
</button>
</div>
);
}

View File

@@ -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<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const selectedProfile = React.useMemo(
() => profiles.find((p) => p.id === selectedProfileId),
[profiles, selectedProfileId]
);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [selectedProfileId]);
// Filter profiles based on input
const filteredProfiles = React.useMemo(() => {
if (!inputValue) return profiles;
const lower = inputValue.toLowerCase();
return profiles.filter(
(p) =>
p.name.toLowerCase().includes(lower) ||
p.description?.toLowerCase().includes(lower) ||
p.provider.toLowerCase().includes(lower)
);
}, [profiles, inputValue]);
const handleSelect = (profile: AIProfile) => {
onSelect(profile);
setInputValue('');
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between h-9', className)}
data-testid={`${testIdPrefix}-trigger`}
>
<span className="flex items-center gap-2 truncate">
{selectedProfile ? (
<>
{(() => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[selectedProfile.provider];
return ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
);
})()}
<span className="truncate">{selectedProfile.name}</span>
</>
) : (
<>
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{placeholder}</span>
</>
)}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: Math.max(triggerWidth, 280) }}
data-testid={`${testIdPrefix}-content`}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search profiles..."
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>No profile found.</CommandEmpty>
<CommandGroup>
{filteredProfiles.map((profile) => {
const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider];
const isSelected = profile.id === selectedProfileId;
const modelDisplay = getProfileModelDisplay(profile);
const thinkingDisplay = getProfileThinkingDisplay(profile);
return (
<CommandItem
key={profile.id}
value={profile.id}
onSelect={() => handleSelect(profile)}
className="flex items-center gap-2 py-2"
data-testid={`${testIdPrefix}-option-${profile.id}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{ProviderIcon ? (
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{profile.name}</span>
<span className="text-xs text-muted-foreground truncate">
{modelDisplay}
{thinkingDisplay && (
<span className="text-amber-500"> + {thinkingDisplay}</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{profile.isBuiltIn && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Built-in
</Badge>
)}
<Check className={cn('h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')} />
</div>
</CommandItem>
);
})}
</CommandGroup>
{showManageLink && onManageLinkClick && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setOpen(false);
onManageLinkClick();
}}
className="text-muted-foreground"
data-testid={`${testIdPrefix}-manage-link`}
>
<Settings2 className="w-4 h-4 mr-2" />
Manage AI Profiles
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
@@ -246,6 +273,20 @@ export function ProfileForm({
<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>
@@ -404,6 +445,61 @@ export function ProfileForm({
</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">

View File

@@ -18,7 +18,12 @@ import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
import { ClaudeSettingsTab, CursorSettingsTab, CodexSettingsTab } from './settings-view/providers';
import {
ClaudeSettingsTab,
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
@@ -109,6 +114,8 @@ export function SettingsView() {
return <CursorSettingsTab />;
case 'codex-provider':
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -0,0 +1,306 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
| 'api_key' // Manually stored API key
| 'oauth' // OAuth authentication
| 'config_file' // Config file with credentials
| 'none';
export interface OpencodeAuthStatus {
authenticated: boolean;
method: OpencodeAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasOAuthToken?: boolean;
error?: string;
}
function getAuthMethodLabel(method: OpencodeAuthMethod): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'api_key_env':
return 'API Key (Environment)';
case 'oauth':
return 'OAuth Authentication';
case 'config_file':
return 'Configuration File';
default:
return method || 'Unknown';
}
}
interface OpencodeCliStatusProps {
status: CliStatus | null;
authStatus?: OpencodeAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function OpencodeCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function OpencodeModelConfigSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-40" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-72" />
</div>
</div>
<div className="p-6 space-y-6">
{/* Default Model skeleton */}
<div className="space-y-2">
<SkeletonPulse className="h-4 w-24" />
<SkeletonPulse className="h-10 w-full rounded-md" />
</div>
{/* Available Models skeleton */}
<div className="space-y-3">
<SkeletonPulse className="h-4 w-32" />
{/* Provider group skeleton */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<SkeletonPulse className="w-4 h-4 rounded" />
<SkeletonPulse className="h-4 w-20" />
</div>
<div className="grid gap-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center justify-between p-3 rounded-xl border border-border/30 bg-muted/10"
>
<div className="flex items-center gap-3">
<SkeletonPulse className="w-5 h-5 rounded" />
<div className="space-y-1.5">
<SkeletonPulse className="h-4 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
<SkeletonPulse className="h-5 w-12 rounded-full" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function OpencodeCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: OpencodeCliStatusProps) {
if (!status) return <OpencodeCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Bot className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">OpenCode CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-opencode-cli"
title="Refresh OpenCode CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
OpenCode CLI provides multi-provider AI support with Claude, GPT, and Gemini models.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">OpenCode CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run{' '}
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
set an API key to authenticate.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">OpenCode CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install OpenCode CLI to use multi-provider AI models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
MessageSquareText,
User,
Shield,
Cpu,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -41,6 +42,7 @@ export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: Cpu },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -7,6 +7,7 @@ export type SettingsViewId =
| 'claude-provider'
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -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 (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as OpencodeModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenCodeIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{model.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground mr-1">
{model.badge}
</span>
)}
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// 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))}
</CommandGroup>
)}
{opencode.length > 0 && (
<CommandGroup heading="OpenCode Models">
{opencode.map((model) => renderOpencodeModelItem(model))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -8,10 +8,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodexModelId } from '@automaker/types';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexModelConfigurationProps {
@@ -160,17 +158,6 @@ export function CodexModelConfiguration({
);
}
function getModelDisplayName(modelId: string): string {
const displayNames: Record<string, string> = {
'codex-gpt-5.2-codex': 'GPT-5.2-Codex',
'codex-gpt-5.1-codex-max': 'GPT-5.1-Codex-Max',
'codex-gpt-5.1-codex-mini': 'GPT-5.1-Codex-Mini',
'codex-gpt-5.2': 'GPT-5.2',
'codex-gpt-5.1': 'GPT-5.1',
};
return displayNames[modelId] || modelId;
}
function supportsReasoningEffort(modelId: string): boolean {
const reasoningModels = [
'codex-gpt-5.2-codex',

View File

@@ -2,3 +2,4 @@ export { ProviderTabs } from './provider-tabs';
export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab';

View File

@@ -0,0 +1,231 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import { AnthropicIcon } from '@/components/ui/provider-icon';
import type { ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
enabledOpencodeModels: OpencodeModelId[];
opencodeDefaultModel: OpencodeModelId;
isSaving: boolean;
onDefaultModelChange: (model: OpencodeModelId) => void;
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
}
/**
* Returns the appropriate icon component for a given OpenCode provider
*/
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> {
switch (provider) {
case 'opencode':
return Terminal;
case 'amazon-bedrock-anthropic':
return AnthropicIcon;
case 'amazon-bedrock-deepseek':
return Brain;
case 'amazon-bedrock-amazon':
return Cloud;
case 'amazon-bedrock-meta':
return Cpu;
case 'amazon-bedrock-mistral':
return Sparkles;
case 'amazon-bedrock-qwen':
return Zap;
default:
return Terminal;
}
}
/**
* Returns a formatted provider label for display
*/
function getProviderLabel(provider: OpencodeProvider): string {
switch (provider) {
case 'opencode':
return 'OpenCode (Free)';
case 'amazon-bedrock-anthropic':
return 'Claude (Bedrock)';
case 'amazon-bedrock-deepseek':
return 'DeepSeek (Bedrock)';
case 'amazon-bedrock-amazon':
return 'Amazon Nova (Bedrock)';
case 'amazon-bedrock-meta':
return 'Meta Llama (Bedrock)';
case 'amazon-bedrock-mistral':
return 'Mistral (Bedrock)';
case 'amazon-bedrock-qwen':
return 'Qwen (Bedrock)';
default:
return provider;
}
}
export function OpencodeModelConfiguration({
enabledOpencodeModels,
opencodeDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: OpencodeModelConfigurationProps) {
// Group models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
(acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = [];
}
acc[model.provider].push(model);
return acc;
},
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
);
// Order: Free tier first, then Claude, then others
const providerOrder: OpencodeProvider[] = [
'opencode',
'amazon-bedrock-anthropic',
'amazon-bedrock-deepseek',
'amazon-bedrock-amazon',
'amazon-bedrock-meta',
'amazon-bedrock-mistral',
'amazon-bedrock-qwen',
];
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which OpenCode models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
{/* Default Model Selection */}
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={opencodeDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as OpencodeModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledOpencodeModels.map((modelId) => {
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
if (!model) return null;
const ProviderIconComponent = getProviderIcon(model.provider);
return (
<SelectItem key={modelId} value={modelId}>
<div className="flex items-center gap-2">
<ProviderIconComponent className="w-4 h-4" />
<span>{model.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Available Models grouped by provider */}
<div className="space-y-4">
<Label>Available Models</Label>
{providerOrder.map((provider) => {
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
const ProviderIconComponent = getProviderIcon(provider);
return (
<div key={provider} className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ProviderIconComponent className="w-4 h-4" />
<span className="font-medium">{getProviderLabel(provider)}</span>
{provider === 'opencode' && (
<Badge
variant="outline"
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
>
Free
</Badge>
)}
</div>
<div className="grid gap-2">
{models.map((model) => {
const isEnabled = enabledOpencodeModels.includes(model.id);
const isDefault = model.id === opencodeDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsVision && (
<Badge variant="outline" className="text-xs">
Vision
</Badge>
)}
{model.tier === 'free' && (
<Badge
variant="outline"
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
>
Free
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import {
OpencodeCliStatus,
OpencodeCliStatusSkeleton,
OpencodeModelConfigSkeleton,
} from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
export function OpencodeSettingsTab() {
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Load OpenCode CLI status on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Set auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
});
}
} catch (error) {
logger.error('Failed to check OpenCode CLI status:', error);
setCliStatus({
success: false,
status: 'not_installed',
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCheckingOpencodeCli(false);
setIsInitialLoading(false);
}
};
checkOpencodeStatus();
}, []);
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Update auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: result.auth.hasApiKey,
hasEnvApiKey: result.auth.hasEnvApiKey,
hasOAuthToken: result.auth.hasOAuthToken,
});
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
}
}, []);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
setIsSaving(true);
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
} catch (error) {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
}
},
[setOpencodeDefaultModel]
);
const handleModelToggle = useCallback(
(model: OpencodeModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
} catch (error) {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
}
},
[toggleOpencodeModel]
);
// Show loading skeleton during initial load
if (isInitialLoading) {
return (
<div className="space-y-6">
<OpencodeCliStatusSkeleton />
<OpencodeModelConfigSkeleton />
</div>
);
}
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
return (
<div className="space-y-6">
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<OpencodeModelConfiguration
enabledOpencodeModels={enabledOpencodeModels}
opencodeDefaultModel={opencodeDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default OpencodeSettingsTab;

View File

@@ -1,18 +1,20 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { Cpu } from 'lucide-react';
import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab';
import { OpencodeSettingsTab } from './opencode-settings-tab';
interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor' | 'codex';
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
}
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
Claude
@@ -25,6 +27,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<OpenAIIcon className="w-4 h-4" />
Codex
</TabsTrigger>
<TabsTrigger value="opencode" className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
OpenCode
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -38,6 +44,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="codex">
<CodexSettingsTab />
</TabsContent>
<TabsContent value="opencode">
<OpencodeSettingsTab />
</TabsContent>
</Tabs>
);
}

View File

@@ -5,9 +5,7 @@ import {
WelcomeStep,
ThemeStep,
CompleteStep,
ClaudeSetupStep,
CursorSetupStep,
CodexSetupStep,
ProvidersSetupStep,
GitHubSetupStep,
} from './setup-view/steps';
import { useNavigate } from '@tanstack/react-router';
@@ -16,20 +14,31 @@ const logger = createLogger('SetupView');
// Main Setup View
export function SetupView() {
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
const { currentStep, setCurrentStep, completeSetup } = useSetupStore();
const navigate = useNavigate();
const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const;
// Simplified steps: welcome, theme, providers (combined), github, complete
const steps = ['welcome', 'theme', 'providers', 'github', 'complete'] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
// Map old step names to new consolidated steps
if (currentStep === 'welcome') return 'welcome';
if (currentStep === 'theme') return 'theme';
if (currentStep === 'cursor') return 'cursor';
if (currentStep === 'codex') return 'codex';
if (
currentStep === 'claude_detect' ||
currentStep === 'claude_auth' ||
currentStep === 'cursor' ||
currentStep === 'codex' ||
currentStep === 'opencode' ||
currentStep === 'providers'
) {
return 'providers';
}
if (currentStep === 'github') return 'github';
return 'complete';
};
const currentIndex = steps.indexOf(getStepName());
const handleNext = (from: string) => {
@@ -40,18 +49,10 @@ export function SetupView() {
setCurrentStep('theme');
break;
case 'theme':
logger.debug('[Setup Flow] Moving to claude_detect step');
setCurrentStep('claude_detect');
logger.debug('[Setup Flow] Moving to providers step');
setCurrentStep('providers');
break;
case 'claude':
logger.debug('[Setup Flow] Moving to cursor step');
setCurrentStep('cursor');
break;
case 'cursor':
logger.debug('[Setup Flow] Moving to codex step');
setCurrentStep('codex');
break;
case 'codex':
case 'providers':
logger.debug('[Setup Flow] Moving to github step');
setCurrentStep('github');
break;
@@ -68,37 +69,15 @@ export function SetupView() {
case 'theme':
setCurrentStep('welcome');
break;
case 'claude':
case 'providers':
setCurrentStep('theme');
break;
case 'cursor':
setCurrentStep('claude_detect');
break;
case 'codex':
setCurrentStep('cursor');
break;
case 'github':
setCurrentStep('codex');
setCurrentStep('providers');
break;
}
};
const handleSkipClaude = () => {
logger.debug('[Setup Flow] Skipping Claude setup');
setSkipClaudeSetup(true);
setCurrentStep('cursor');
};
const handleSkipCursor = () => {
logger.debug('[Setup Flow] Skipping Cursor setup');
setCurrentStep('codex');
};
const handleSkipCodex = () => {
logger.debug('[Setup Flow] Skipping Codex setup');
setCurrentStep('github');
};
const handleSkipGithub = () => {
logger.debug('[Setup Flow] Skipping GitHub setup');
setCurrentStep('complete');
@@ -137,27 +116,15 @@ export function SetupView() {
<ThemeStep onNext={() => handleNext('theme')} onBack={() => handleBack('theme')} />
)}
{(currentStep === 'claude_detect' || currentStep === 'claude_auth') && (
<ClaudeSetupStep
onNext={() => handleNext('claude')}
onBack={() => handleBack('claude')}
onSkip={handleSkipClaude}
/>
)}
{currentStep === 'cursor' && (
<CursorSetupStep
onNext={() => handleNext('cursor')}
onBack={() => handleBack('cursor')}
onSkip={handleSkipCursor}
/>
)}
{currentStep === 'codex' && (
<CodexSetupStep
onNext={() => handleNext('codex')}
onBack={() => handleBack('codex')}
onSkip={handleSkipCodex}
{(currentStep === 'providers' ||
currentStep === 'claude_detect' ||
currentStep === 'claude_auth' ||
currentStep === 'cursor' ||
currentStep === 'codex' ||
currentStep === 'opencode') && (
<ProvidersSetupStep
onNext={() => handleNext('providers')}
onBack={() => handleBack('providers')}
/>
)}

View File

@@ -38,6 +38,11 @@ interface ClaudeSetupStepProps {
onSkip: () => void;
}
interface ClaudeSetupContentProps {
/** Hide header and navigation for embedded use */
embedded?: boolean;
}
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
// Claude Setup Step

View File

@@ -2,7 +2,11 @@
export { WelcomeStep } from './welcome-step';
export { ThemeStep } from './theme-step';
export { CompleteStep } from './complete-step';
export { ProvidersSetupStep } from './providers-setup-step';
export { GitHubSetupStep } from './github-setup-step';
// Legacy individual step exports (kept for backwards compatibility)
export { ClaudeSetupStep } from './claude-setup-step';
export { CursorSetupStep } from './cursor-setup-step';
export { CodexSetupStep } from './codex-setup-step';
export { GitHubSetupStep } from './github-setup-step';
export { OpencodeSetupStep } from './opencode-setup-step';

View File

@@ -0,0 +1,369 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
ArrowRight,
ArrowLeft,
ExternalLink,
Copy,
RefreshCw,
AlertTriangle,
XCircle,
Terminal,
} from 'lucide-react';
import { toast } from 'sonner';
import { StatusBadge } from '../components';
const logger = createLogger('OpencodeSetupStep');
interface OpencodeSetupStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
interface OpencodeCliStatus {
installed: boolean;
version?: string | null;
path?: string | null;
auth?: {
authenticated: boolean;
method: string;
};
installCommand?: string;
loginCommand?: string;
}
export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepProps) {
const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore();
const [isChecking, setIsChecking] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getOpencodeStatus) {
return;
}
const result = await api.setup.getOpencodeStatus();
if (result.success) {
const status: OpencodeCliStatus = {
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
};
setOpencodeCliStatus(status);
if (result.auth?.authenticated) {
toast.success('OpenCode CLI is ready!');
}
}
} catch (error) {
logger.error('Failed to check OpenCode status:', error);
} finally {
setIsChecking(false);
}
}, [setOpencodeCliStatus]);
useEffect(() => {
checkStatus();
// Cleanup polling on unmount
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
// Copy login command to clipboard and show instructions
const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login';
await navigator.clipboard.writeText(loginCommand);
toast.info('Login command copied! Paste in terminal to authenticate.');
// Poll for auth status
let attempts = 0;
const maxAttempts = 60; // 2 minutes with 2s interval
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getOpencodeStatus) {
return;
}
const result = await api.setup.getOpencodeStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setOpencodeCliStatus({
...opencodeCliStatus,
installed: result.installed ?? true,
version: result.version,
path: result.path,
auth: result.auth,
} as OpencodeCliStatus);
setIsLoggingIn(false);
toast.success('Successfully logged in to OpenCode!');
}
} catch {
// Ignore polling errors
}
if (attempts >= maxAttempts) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch (error) {
logger.error('Login failed:', error);
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated;
const getStatusBadge = () => {
if (isChecking) {
return <StatusBadge status="checking" label="Checking..." />;
}
if (opencodeCliStatus?.auth?.authenticated) {
return <StatusBadge status="authenticated" label="Ready" />;
}
if (opencodeCliStatus?.installed) {
return <StatusBadge status="unverified" label="Not Logged In" />;
}
return <StatusBadge status="not_installed" label="Not Installed" />;
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">OpenCode CLI Setup</h2>
<p className="text-muted-foreground">Optional - Use OpenCode as an AI provider</p>
</div>
{/* Info Banner */}
<Card className="bg-green-500/10 border-green-500/20">
<CardContent className="pt-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-green-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-foreground">This step is optional</p>
<p className="text-sm text-muted-foreground mt-1">
Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You
can skip this and use other providers, or configure it later in Settings.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Status Card */}
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Terminal className="w-5 h-5" />
OpenCode CLI Status
<Badge variant="outline" className="ml-2">
Optional
</Badge>
</CardTitle>
<div className="flex items-center gap-2">
{getStatusBadge()}
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<CardDescription>
{opencodeCliStatus?.installed
? opencodeCliStatus.auth?.authenticated
? `Authenticated via ${opencodeCliStatus.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}`
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Success State */}
{isReady && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">OpenCode CLI is ready!</p>
<p className="text-sm text-muted-foreground">
You can use OpenCode models for AI tasks.
{opencodeCliStatus?.version && (
<span className="ml-1">Version: {opencodeCliStatus.version}</span>
)}
</p>
</div>
</div>
)}
{/* Not Installed */}
{!opencodeCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI to use free tier and AWS Bedrock models.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install OpenCode CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{opencodeCliStatus?.installCommand || 'npm install -g opencode'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(opencodeCliStatus?.installCommand || 'npm install -g opencode')
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<a
href="https://github.com/opencode-ai/opencode"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
>
View installation docs
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
)}
{/* Installed but not authenticated */}
{opencodeCliStatus?.installed &&
!opencodeCliStatus?.auth?.authenticated &&
!isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command to authenticate with OpenCode.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="text-sm text-muted-foreground">
Run the login command in your terminal, then complete authentication in your
browser:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{opencodeCliStatus?.loginCommand || 'opencode auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(opencodeCliStatus?.loginCommand || 'opencode auth login')
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleLogin}
disabled={isLoggingIn}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isLoggingIn ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Waiting for login...
</>
) : (
'Copy Command & Wait for Login'
)}
</Button>
</div>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">Checking OpenCode CLI status...</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
{isReady ? 'Skip' : 'Skip for now'}
</Button>
<Button
onClick={onNext}
className="bg-brand-500 hover:bg-brand-600 text-white"
data-testid="opencode-next-button"
>
{isReady ? 'Continue' : 'Continue without OpenCode'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
{/* Info note */}
<p className="text-xs text-muted-foreground text-center">
You can always configure OpenCode later in Settings
</p>
</div>
);
}

File diff suppressed because it is too large Load Diff