From 914734cff6a333e64a55d604cb89a4bef113e19a Mon Sep 17 00:00:00 2001 From: Shirone Date: Fri, 2 Jan 2026 02:37:20 +0100 Subject: [PATCH] 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. --- .../phase-models/phase-model-selector.tsx | 239 +++++++++++++++++- libs/types/src/cursor-models.ts | 167 ++++++++++++ 2 files changed, 397 insertions(+), 9 deletions(-) diff --git a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx index 256d9006..e783cd99 100644 --- a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx @@ -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(null); + const commandListRef = React.useRef(null); + const expandedTriggerRef = React.useRef(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(); + + 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 ( + setExpandedGroup(isExpanded ? null : group.baseId)} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedGroup(null); + } + }} + > + +
+
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+
+
+
+ ); + }; + return (
- + No model found. {favorites.length > 0 && ( <> - {favorites.map((model) => - renderModelItem(model, model.provider === 'claude' ? 'claude' : 'cursor') - )} + {(() => { + const renderedGroups = new Set(); + 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' + ); + }); + })()} @@ -188,9 +406,12 @@ export function PhaseModelSelector({ )} - {cursor.length > 0 && ( + {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( - {cursor.map((model) => renderModelItem(model, 'cursor'))} + {/* Grouped models with secondary popover */} + {groupedModels.map((group) => renderGroupedModelItem(group))} + {/* Standalone models */} + {standaloneCursorModels.map((model) => renderModelItem(model, 'cursor'))} )} diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts index 73fe574e..4deb8b52 100644 --- a/libs/types/src/cursor-models.ts +++ b/libs/types/src/cursor-models.ts @@ -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)); +}