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:
Shirone
2026-01-02 02:37:20 +01:00
parent e1bdb4c7df
commit 914734cff6
2 changed files with 397 additions and 9 deletions

View File

@@ -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>

View File

@@ -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));
}