mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
refactor: rename phase models to model defaults and reorganize components
- Updated imports and references from 'phase-models' to 'model-defaults' across various components. - Removed obsolete phase models index file to streamline the codebase.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { ModelDefaultsSection } from './model-defaults-section';
|
||||
export { PhaseModelSelector } from './phase-model-selector';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Workflow, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PhaseModelSelector } from './phase-model-selector';
|
||||
import type { PhaseModelKey } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
interface PhaseConfig {
|
||||
key: PhaseModelKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const QUICK_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'enhancementModel',
|
||||
label: 'Feature Enhancement',
|
||||
description: 'Improves feature names and descriptions',
|
||||
},
|
||||
{
|
||||
key: 'fileDescriptionModel',
|
||||
label: 'File Descriptions',
|
||||
description: 'Generates descriptions for context files',
|
||||
},
|
||||
{
|
||||
key: 'imageDescriptionModel',
|
||||
label: 'Image Descriptions',
|
||||
description: 'Analyzes and describes context images',
|
||||
},
|
||||
];
|
||||
|
||||
const VALIDATION_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'validationModel',
|
||||
label: 'GitHub Issue Validation',
|
||||
description: 'Validates and improves GitHub issues',
|
||||
},
|
||||
];
|
||||
|
||||
const GENERATION_TASKS: PhaseConfig[] = [
|
||||
{
|
||||
key: 'specGenerationModel',
|
||||
label: 'App Specification',
|
||||
description: 'Generates full application specifications',
|
||||
},
|
||||
{
|
||||
key: 'featureGenerationModel',
|
||||
label: 'Feature Generation',
|
||||
description: 'Creates features from specifications',
|
||||
},
|
||||
{
|
||||
key: 'backlogPlanningModel',
|
||||
label: 'Backlog Planning',
|
||||
description: 'Reorganizes and prioritizes backlog',
|
||||
},
|
||||
{
|
||||
key: 'projectAnalysisModel',
|
||||
label: 'Project Analysis',
|
||||
description: 'Analyzes project structure for suggestions',
|
||||
},
|
||||
{
|
||||
key: 'suggestionsModel',
|
||||
label: 'AI Suggestions',
|
||||
description: 'Model for feature, refactoring, security, and performance suggestions',
|
||||
},
|
||||
];
|
||||
|
||||
function PhaseGroup({
|
||||
title,
|
||||
subtitle,
|
||||
phases,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
phases: PhaseConfig[];
|
||||
}) {
|
||||
const { phaseModels, setPhaseModel } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{phases.map((phase) => (
|
||||
<PhaseModelSelector
|
||||
key={phase.key}
|
||||
label={phase.label}
|
||||
description={phase.description}
|
||||
value={phaseModels[phase.key] ?? DEFAULT_PHASE_MODELS[phase.key]}
|
||||
onChange={(model) => setPhaseModel(phase.key, model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelDefaultsSection() {
|
||||
const { resetPhaseModels } = useAppStore();
|
||||
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<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">
|
||||
<Workflow className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Model Defaults
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Configure which AI model to use for each application task
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Quick Tasks */}
|
||||
<PhaseGroup
|
||||
title="Quick Tasks"
|
||||
subtitle="Fast models recommended for speed and cost savings"
|
||||
phases={QUICK_TASKS}
|
||||
/>
|
||||
|
||||
{/* Validation Tasks */}
|
||||
<PhaseGroup
|
||||
title="Validation Tasks"
|
||||
subtitle="Smart models recommended for accuracy"
|
||||
phases={VALIDATION_TASKS}
|
||||
/>
|
||||
|
||||
{/* Generation Tasks */}
|
||||
<PhaseGroup
|
||||
title="Generation Tasks"
|
||||
subtitle="Powerful models recommended for quality output"
|
||||
phases={GENERATION_TASKS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type {
|
||||
ModelAlias,
|
||||
CursorModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
stripProviderPrefix,
|
||||
STANDALONE_CURSOR_MODELS,
|
||||
getModelGroup,
|
||||
isGroupSelected,
|
||||
getSelectedVariant,
|
||||
isCursorModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
} from '@/components/views/board-view/shared/model-constants';
|
||||
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
|
||||
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';
|
||||
|
||||
interface PhaseModelSelectorProps {
|
||||
/** Label shown in full mode */
|
||||
label?: string;
|
||||
/** Description shown in full mode */
|
||||
description?: string;
|
||||
/** Current model selection */
|
||||
value: PhaseModelEntry;
|
||||
/** Callback when model is selected */
|
||||
onChange: (entry: PhaseModelEntry) => void;
|
||||
/** Compact mode - just shows the button trigger without label/description wrapper */
|
||||
compact?: boolean;
|
||||
/** Custom trigger class name */
|
||||
triggerClassName?: string;
|
||||
/** Popover alignment */
|
||||
align?: 'start' | 'end';
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PhaseModelSelector({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
triggerClassName,
|
||||
align = 'end',
|
||||
disabled = false,
|
||||
}: PhaseModelSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
|
||||
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
|
||||
const commandListRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
|
||||
|
||||
// Extract model and thinking level from value
|
||||
const selectedModel = value.model;
|
||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||
|
||||
// Close expanded group when trigger scrolls out of view
|
||||
React.useEffect(() => {
|
||||
const triggerElement = expandedTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedGroup) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry.isIntersecting) {
|
||||
setExpandedGroup(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: listElement,
|
||||
threshold: 0.1, // Close when less than 10% visible
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(triggerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [expandedGroup]);
|
||||
|
||||
// Close expanded Claude model popover when trigger scrolls out of view
|
||||
React.useEffect(() => {
|
||||
const triggerElement = expandedClaudeTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedClaudeModel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry.isIntersecting) {
|
||||
setExpandedClaudeModel(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: listElement,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(triggerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [expandedClaudeModel]);
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
return enabledCursorModels.includes(cursorId);
|
||||
});
|
||||
|
||||
// Helper to find current selected model details
|
||||
const currentModel = React.useMemo(() => {
|
||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
||||
if (claudeModel) {
|
||||
// Add thinking level to label if not 'none'
|
||||
const thinkingLabel =
|
||||
selectedThinkingLevel !== 'none'
|
||||
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
|
||||
: '';
|
||||
return {
|
||||
...claudeModel,
|
||||
label: `${claudeModel.label}${thinkingLabel}`,
|
||||
icon: Brain,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorModel = availableCursorModels.find(
|
||||
(m) => stripProviderPrefix(m.id) === selectedModel
|
||||
);
|
||||
if (cursorModel) return { ...cursorModel, icon: Sparkles };
|
||||
|
||||
// Check if selectedModel is part of a grouped model
|
||||
const group = getModelGroup(selectedModel as CursorModelId);
|
||||
if (group) {
|
||||
const variant = getSelectedVariant(group, selectedModel as CursorModelId);
|
||||
return {
|
||||
id: selectedModel,
|
||||
label: `${group.label} (${variant?.label || 'Unknown'})`,
|
||||
description: group.description,
|
||||
provider: 'cursor' as const,
|
||||
icon: Sparkles,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
|
||||
|
||||
// Compute grouped vs standalone Cursor models
|
||||
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
|
||||
const grouped: GroupedModel[] = [];
|
||||
const standalone: typeof CURSOR_MODELS = [];
|
||||
const seenGroups = new Set<string>();
|
||||
|
||||
availableCursorModels.forEach((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
|
||||
// Check if this model is standalone
|
||||
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
|
||||
standalone.push(model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this model belongs to a group
|
||||
const group = getModelGroup(cursorId);
|
||||
if (group && !seenGroups.has(group.baseId)) {
|
||||
// Filter variants to only include enabled models
|
||||
const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id));
|
||||
if (enabledVariants.length > 0) {
|
||||
grouped.push({
|
||||
...group,
|
||||
variants: enabledVariants,
|
||||
});
|
||||
seenGroups.add(group.baseId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
||||
}, [availableCursorModels, enabledCursorModels]);
|
||||
|
||||
// Group models
|
||||
const { favorites, claude, cursor } = React.useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
const curModels: typeof CURSOR_MODELS = [];
|
||||
|
||||
// Process Claude Models
|
||||
CLAUDE_MODELS.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
cModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
// Process Cursor Models
|
||||
availableCursorModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
curModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
return { favorites: favs, claude: cModels, cursor: curModels };
|
||||
}, [favoriteModels, availableCursorModels]);
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
const modelValue = stripProviderPrefix(model.id);
|
||||
const isSelected = selectedModel === modelValue;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: modelValue as CursorModelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
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">
|
||||
<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 Claude model item with secondary popover for thinking level
|
||||
const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => {
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
const isExpanded = expandedClaudeModel === model.id;
|
||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<Popover
|
||||
open={isExpanded}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setExpandedClaudeModel(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={isExpanded ? expandedClaudeTriggerRef : undefined}
|
||||
className={cn(
|
||||
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
|
||||
'hover:bg-accent',
|
||||
isExpanded && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Brain
|
||||
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">
|
||||
{isSelected && currentThinking !== 'none'
|
||||
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||
: model.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<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" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="center"
|
||||
avoidCollisions={false}
|
||||
className="w-[220px] p-1"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
model: model.id as ModelAlias,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
setExpandedClaudeModel(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{THINKING_LEVEL_LABELS[level]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level === 'none' && 'No extended thinking'}
|
||||
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a grouped model with secondary popover for variant selection
|
||||
const renderGroupedModelItem = (group: GroupedModel) => {
|
||||
const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId);
|
||||
const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId);
|
||||
const isExpanded = expandedGroup === group.baseId;
|
||||
|
||||
const variantTypeLabel =
|
||||
group.variantType === 'compute'
|
||||
? 'Compute Level'
|
||||
: group.variantType === 'thinking'
|
||||
? 'Reasoning Mode'
|
||||
: 'Capacity Options';
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={group.baseId}
|
||||
value={group.label}
|
||||
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<Popover
|
||||
open={isExpanded}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setExpandedGroup(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={isExpanded ? expandedTriggerRef : undefined}
|
||||
className={cn(
|
||||
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
|
||||
'hover:bg-accent',
|
||||
isExpanded && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{groupIsSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="center"
|
||||
avoidCollisions={false}
|
||||
className="w-[220px] p-1"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
{variantTypeLabel}
|
||||
</div>
|
||||
{group.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => {
|
||||
onChange({ model: variant.id });
|
||||
setExpandedGroup(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
selectedModel === variant.id && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{variant.label}</span>
|
||||
{variant.description && (
|
||||
<span className="text-xs text-muted-foreground">{variant.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{variant.badge && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{variant.badge}
|
||||
</span>
|
||||
)}
|
||||
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Compact trigger button (for agent view etc.)
|
||||
const compactTrigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5',
|
||||
triggerClassName
|
||||
)}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
{currentModel?.icon && <currentModel.icon className="h-4 w-4 text-muted-foreground/70" />}
|
||||
<span className="truncate text-sm">
|
||||
{currentModel?.label?.replace('Claude ', '') || 'Select model...'}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
// Full trigger button (for settings view)
|
||||
const fullTrigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-[260px] justify-between h-9 px-3 bg-background/50 border-border/50 hover:bg-background/80 hover:text-foreground',
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{currentModel?.icon && <currentModel.icon className="h-4 w-4 text-muted-foreground/70" />}
|
||||
<span className="truncate text-sm">{currentModel?.label || 'Select model...'}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
// The popover content (shared between both modes)
|
||||
const popoverContent = (
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0"
|
||||
align={align}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList
|
||||
ref={commandListRef}
|
||||
className="max-h-[300px] overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
|
||||
{favorites.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Favorites">
|
||||
{(() => {
|
||||
const renderedGroups = new Set<string>();
|
||||
return favorites.map((model) => {
|
||||
// Check if this favorite is part of a grouped model
|
||||
if (model.provider === 'cursor') {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
const group = getModelGroup(cursorId);
|
||||
if (group) {
|
||||
// Skip if we already rendered this group
|
||||
if (renderedGroups.has(group.baseId)) {
|
||||
return null;
|
||||
}
|
||||
renderedGroups.add(group.baseId);
|
||||
// Find the group in groupedModels (which has filtered variants)
|
||||
const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId);
|
||||
if (filteredGroup) {
|
||||
return renderGroupedModelItem(filteredGroup);
|
||||
}
|
||||
}
|
||||
// Standalone Cursor model
|
||||
return renderCursorModelItem(model);
|
||||
}
|
||||
// Claude model
|
||||
return renderClaudeModelItem(model);
|
||||
});
|
||||
})()}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{claude.length > 0 && (
|
||||
<CommandGroup heading="Claude Models">
|
||||
{claude.map((model) => renderClaudeModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
<CommandGroup heading="Cursor Models">
|
||||
{/* Grouped models with secondary popover */}
|
||||
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
||||
{/* Standalone models */}
|
||||
{standaloneCursorModels.map((model) => renderCursorModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
);
|
||||
|
||||
// Compact mode - just the popover with compact trigger
|
||||
if (compact) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>{compactTrigger}</PopoverTrigger>
|
||||
{popoverContent}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Full mode - with label and description wrapper
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-xl',
|
||||
'bg-accent/20 border border-border/30',
|
||||
'hover:bg-accent/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
{/* Label and Description */}
|
||||
<div className="flex-1 pr-4">
|
||||
<h4 className="text-sm font-medium text-foreground">{label}</h4>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Model Selection Popover */}
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>{fullTrigger}</PopoverTrigger>
|
||||
{popoverContent}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user