mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat(phase-model-selector): implement grouped model selection and enhanced UI
- Added support for grouped models in the PhaseModelSelector, allowing users to select from multiple variants within a single group. - Introduced a new popover UI for displaying grouped model variants, improving user interaction and selection clarity. - Implemented logic to filter and display enabled cursor models, including standalone and grouped options. - Enhanced state management for expanded groups and variant selection, ensuring a smoother user experience. This update significantly improves the model selection process, making it more intuitive and organized.
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelAlias, CursorModelId } from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import type { ModelAlias, CursorModelId, GroupedModel } from '@automaker/types';
|
||||
import {
|
||||
stripProviderPrefix,
|
||||
CURSOR_MODEL_GROUPS,
|
||||
STANDALONE_CURSOR_MODELS,
|
||||
getModelGroup,
|
||||
isGroupSelected,
|
||||
getSelectedVariant,
|
||||
} from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||
import { Check, ChevronsUpDown, Star, Brain, Sparkles } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
@@ -31,8 +38,34 @@ export function PhaseModelSelector({
|
||||
onChange,
|
||||
}: PhaseModelSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
|
||||
const commandListRef = React.useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
|
||||
|
||||
// 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]);
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
@@ -47,9 +80,55 @@ export function PhaseModelSelector({
|
||||
const cursorModel = availableCursorModels.find((m) => stripProviderPrefix(m.id) === value);
|
||||
if (cursorModel) return { ...cursorModel, icon: Sparkles };
|
||||
|
||||
// Check if value is part of a grouped model
|
||||
const group = getModelGroup(value as CursorModelId);
|
||||
if (group) {
|
||||
const variant = getSelectedVariant(group, value as CursorModelId);
|
||||
return {
|
||||
id: value,
|
||||
label: `${group.label} (${variant?.label || 'Unknown'})`,
|
||||
description: group.description,
|
||||
provider: 'cursor' as const,
|
||||
icon: Sparkles,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [value, 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 = [];
|
||||
@@ -133,6 +212,120 @@ export function PhaseModelSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render a grouped model with secondary popover for variant selection
|
||||
const renderGroupedModelItem = (group: GroupedModel) => {
|
||||
const groupIsSelected = isGroupSelected(group, value as CursorModelId);
|
||||
const selectedVariant = getSelectedVariant(group, value 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(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',
|
||||
value === 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>
|
||||
)}
|
||||
{value === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -168,15 +361,40 @@ export function PhaseModelSelector({
|
||||
<PopoverContent className="w-[320px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList className="max-h-[300px]">
|
||||
<CommandList ref={commandListRef} className="max-h-[300px]">
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
|
||||
{favorites.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Favorites">
|
||||
{favorites.map((model) =>
|
||||
renderModelItem(model, model.provider === 'claude' ? 'claude' : 'cursor')
|
||||
)}
|
||||
{(() => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return renderModelItem(
|
||||
model,
|
||||
model.provider === 'claude' ? 'claude' : 'cursor'
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
@@ -188,9 +406,12 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{cursor.length > 0 && (
|
||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
<CommandGroup heading="Cursor Models">
|
||||
{cursor.map((model) => renderModelItem(model, 'cursor'))}
|
||||
{/* Grouped models with secondary popover */}
|
||||
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
||||
{/* Standalone models */}
|
||||
{standaloneCursorModels.map((model) => renderModelItem(model, 'cursor'))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
@@ -166,3 +166,170 @@ export function getCursorModelLabel(modelId: CursorModelId): string {
|
||||
export function getAllCursorModelIds(): CursorModelId[] {
|
||||
return Object.keys(CURSOR_MODEL_MAP) as CursorModelId[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Grouping System
|
||||
// Groups related model variants (e.g., gpt-5.2 + gpt-5.2-high) for UI display
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type of variant options available for grouped models
|
||||
*/
|
||||
export type VariantType = 'compute' | 'thinking' | 'capacity';
|
||||
|
||||
/**
|
||||
* A single variant option within a grouped model
|
||||
*/
|
||||
export interface ModelVariant {
|
||||
id: CursorModelId;
|
||||
label: string;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grouped model that contains multiple variant options
|
||||
*/
|
||||
export interface GroupedModel {
|
||||
baseId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
variantType: VariantType;
|
||||
variants: ModelVariant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for grouping Cursor models with variants
|
||||
*/
|
||||
export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
|
||||
// GPT-5.2 group (compute levels)
|
||||
{
|
||||
baseId: 'gpt-5.2-group',
|
||||
label: 'GPT-5.2',
|
||||
description: 'OpenAI GPT-5.2 via Cursor',
|
||||
variantType: 'compute',
|
||||
variants: [
|
||||
{ id: 'gpt-5.2', label: 'Standard', description: 'Default compute level' },
|
||||
{
|
||||
id: 'gpt-5.2-high',
|
||||
label: 'High',
|
||||
description: 'High compute level',
|
||||
badge: 'More tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
// GPT-5.1 group (compute levels)
|
||||
{
|
||||
baseId: 'gpt-5.1-group',
|
||||
label: 'GPT-5.1',
|
||||
description: 'OpenAI GPT-5.1 via Cursor',
|
||||
variantType: 'compute',
|
||||
variants: [
|
||||
{ id: 'gpt-5.1', label: 'Standard', description: 'Default compute level' },
|
||||
{
|
||||
id: 'gpt-5.1-high',
|
||||
label: 'High',
|
||||
description: 'High compute level',
|
||||
badge: 'More tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
// GPT-5.1 Codex group (capacity + compute matrix)
|
||||
{
|
||||
baseId: 'gpt-5.1-codex-group',
|
||||
label: 'GPT-5.1 Codex',
|
||||
description: 'OpenAI GPT-5.1 Codex for code generation',
|
||||
variantType: 'capacity',
|
||||
variants: [
|
||||
{ id: 'gpt-5.1-codex', label: 'Standard', description: 'Default capacity' },
|
||||
{ id: 'gpt-5.1-codex-high', label: 'High', description: 'High compute', badge: 'Compute' },
|
||||
{ id: 'gpt-5.1-codex-max', label: 'Max', description: 'Maximum capacity', badge: 'Capacity' },
|
||||
{
|
||||
id: 'gpt-5.1-codex-max-high',
|
||||
label: 'Max High',
|
||||
description: 'Max capacity + high compute',
|
||||
badge: 'Premium',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Sonnet 4.5 group (thinking mode)
|
||||
{
|
||||
baseId: 'sonnet-4.5-group',
|
||||
label: 'Claude Sonnet 4.5',
|
||||
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
|
||||
variantType: 'thinking',
|
||||
variants: [
|
||||
{ id: 'sonnet-4.5', label: 'Standard', description: 'Fast responses' },
|
||||
{
|
||||
id: 'sonnet-4.5-thinking',
|
||||
label: 'Thinking',
|
||||
description: 'Extended reasoning',
|
||||
badge: 'Reasoning',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Opus 4.5 group (thinking mode)
|
||||
{
|
||||
baseId: 'opus-4.5-group',
|
||||
label: 'Claude Opus 4.5',
|
||||
description: 'Anthropic Claude Opus 4.5 via Cursor',
|
||||
variantType: 'thinking',
|
||||
variants: [
|
||||
{ id: 'opus-4.5', label: 'Standard', description: 'Fast responses' },
|
||||
{
|
||||
id: 'opus-4.5-thinking',
|
||||
label: 'Thinking',
|
||||
description: 'Extended reasoning',
|
||||
badge: 'Reasoning',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Cursor models that are not part of any group (standalone)
|
||||
*/
|
||||
export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [
|
||||
'auto',
|
||||
'composer-1',
|
||||
'opus-4.1',
|
||||
'gemini-3-pro',
|
||||
'gemini-3-flash',
|
||||
'grok',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the group that a model belongs to (if any)
|
||||
*/
|
||||
export function getModelGroup(modelId: CursorModelId): GroupedModel | undefined {
|
||||
return CURSOR_MODEL_GROUPS.find((group) => group.variants.some((v) => v.id === modelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any variant in a group is the currently selected model
|
||||
*/
|
||||
export function isGroupSelected(
|
||||
group: GroupedModel,
|
||||
currentModelId: CursorModelId | undefined
|
||||
): boolean {
|
||||
if (!currentModelId) return false;
|
||||
return group.variants.some((v) => v.id === currentModelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected variant within a group
|
||||
*/
|
||||
export function getSelectedVariant(
|
||||
group: GroupedModel,
|
||||
currentModelId: CursorModelId | undefined
|
||||
): ModelVariant | undefined {
|
||||
if (!currentModelId) return undefined;
|
||||
return group.variants.find((v) => v.id === currentModelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model ID belongs to a group
|
||||
*/
|
||||
export function isGroupedCursorModel(modelId: CursorModelId): boolean {
|
||||
return CURSOR_MODEL_GROUPS.some((group) => group.variants.some((v) => v.id === modelId));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user