feat(ui): display plan tiers and presets in copilot model selector

- Add CopilotPlanTier type and extend CopilotModel with minPlan,
  multiplier, preview fields
- Show plan tier badges (free/pro/pro+/business/enterprise) with
  color-coded styling in model dropdown
- Display multiplier info (0x=free, 1x=standard, 3x-10x=premium)
- Add Preview badge for non-GA models
- Create 3 free tier presets: GPT-4.1, GPT-5 Mini, Claude Haiku 4.5
- Create 5 paid presets: Claude Opus/Sonnet 4.5, GPT-5.2,
  GPT-5.1 Codex Max, Gemini 2.5 Pro
- Separate presets into Free Tier and Pro+ Required sections
This commit is contained in:
kaitranntt
2025-12-18 04:15:08 -05:00
parent 7653caba71
commit 87c2acc416
2 changed files with 193 additions and 31 deletions
+184 -31
View File
@@ -27,7 +27,7 @@ import {
SelectGroup, SelectGroup,
SelectLabel, SelectLabel,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCopilot } from '@/hooks/use-copilot'; import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use-copilot';
import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
@@ -38,27 +38,74 @@ const CodeEditor = lazy(() =>
); );
// Model presets for quick configuration // Model presets for quick configuration
const MODEL_PRESETS = [ // Grouped by tier: Free (available to all) and Paid (requires Pro+)
const FREE_PRESETS = [
{
name: 'GPT-4.1 (Free)',
description: 'Free tier - no premium usage',
default: 'gpt-4.1',
opus: 'gpt-4.1',
sonnet: 'gpt-4.1',
haiku: 'gpt-4.1',
},
{
name: 'GPT-5 Mini (Free)',
description: 'Free tier - lightweight model',
default: 'gpt-5-mini',
opus: 'gpt-5-mini',
sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini',
},
{
name: 'Claude Haiku 4.5 (Free)',
description: 'Free tier - fast Anthropic model',
default: 'claude-haiku-4.5',
opus: 'claude-haiku-4.5',
sonnet: 'claude-haiku-4.5',
haiku: 'claude-haiku-4.5',
},
];
const PAID_PRESETS = [
{ {
name: 'Claude Opus 4.5', name: 'Claude Opus 4.5',
default: 'claude-opus-4-5-20250514', description: 'Pro+ (3x) - Most capable reasoning',
opus: 'claude-opus-4-5-20250514', default: 'claude-opus-4.5',
sonnet: 'claude-sonnet-4-20250514', opus: 'claude-opus-4.5',
haiku: 'claude-haiku-3-5-20250514', sonnet: 'claude-sonnet-4.5',
haiku: 'claude-haiku-4.5',
}, },
{ {
name: 'Claude Sonnet 4', name: 'Claude Sonnet 4.5',
default: 'claude-sonnet-4-20250514', description: 'Pro+ (1x) - Balanced performance',
opus: 'claude-sonnet-4-20250514', default: 'claude-sonnet-4.5',
sonnet: 'claude-sonnet-4-20250514', opus: 'claude-opus-4.5',
haiku: 'claude-haiku-3-5-20250514', sonnet: 'claude-sonnet-4.5',
haiku: 'claude-haiku-4.5',
}, },
{ {
name: 'GPT-4o', name: 'GPT-5.2',
default: 'gpt-4o', description: 'Pro+ (1x) - Latest OpenAI (Preview)',
opus: 'gpt-4o', default: 'gpt-5.2',
sonnet: 'gpt-4o', opus: 'gpt-5.2',
haiku: 'gpt-4o-mini', sonnet: 'gpt-5.1',
haiku: 'gpt-5-mini',
},
{
name: 'GPT-5.1 Codex Max',
description: 'Pro+ (1x) - Best for coding',
default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max',
sonnet: 'gpt-5.1-codex',
haiku: 'gpt-5.1-codex-mini',
},
{
name: 'Gemini 2.5 Pro',
description: 'Pro+ (1x) - Google latest',
default: 'gemini-2.5-pro',
opus: 'gemini-2.5-pro',
sonnet: 'gemini-2.5-pro',
haiku: 'gemini-3-flash',
}, },
]; ];
@@ -67,10 +114,37 @@ interface FlexibleModelSelectorProps {
description?: string; description?: string;
value: string | undefined; value: string | undefined;
onChange: (model: string) => void; onChange: (model: string) => void;
models: { id: string }[]; models: CopilotModel[];
disabled?: boolean; disabled?: boolean;
} }
/** Get badge style for plan tier */
function getPlanBadgeStyle(plan?: CopilotPlanTier): string {
switch (plan) {
case 'free':
return 'bg-green-100 text-green-700 border-green-200';
case 'pro':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'pro+':
return 'bg-purple-100 text-purple-700 border-purple-200';
case 'business':
return 'bg-orange-100 text-orange-700 border-orange-200';
case 'enterprise':
return 'bg-red-100 text-red-700 border-red-200';
default:
return 'bg-muted text-muted-foreground';
}
}
/** Get multiplier display */
function getMultiplierDisplay(multiplier?: number): string | null {
if (multiplier === undefined || multiplier === null) return null;
if (multiplier === 0) return 'Free';
if (multiplier < 1) return `${multiplier}x`;
if (multiplier === 1) return '1x';
return `${multiplier}x`;
}
function FlexibleModelSelector({ function FlexibleModelSelector({
label, label,
description, description,
@@ -79,6 +153,9 @@ function FlexibleModelSelector({
models, models,
disabled, disabled,
}: FlexibleModelSelectorProps) { }: FlexibleModelSelectorProps) {
// Find current model for display
const currentModel = models.find((m) => m.id === value);
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div> <div>
@@ -88,7 +165,19 @@ function FlexibleModelSelector({
<Select value={value || ''} onValueChange={onChange} disabled={disabled}> <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue placeholder="Select model"> <SelectValue placeholder="Select model">
{value && <span className="truncate font-mono text-xs">{value}</span>} {value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
{currentModel?.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
>
{currentModel.minPlan}
</Badge>
)}
</div>
)}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-[300px]"> <SelectContent className="max-h-[300px]">
@@ -99,7 +188,25 @@ function FlexibleModelSelector({
{models.map((model) => ( {models.map((model) => (
<SelectItem key={model.id} value={model.id}> <SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span> <span className="truncate font-mono text-xs">{model.name || model.id}</span>
{model.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
>
{model.minPlan}
</Badge>
)}
{model.multiplier !== undefined && (
<span className="text-[9px] text-muted-foreground">
{getMultiplierDisplay(model.multiplier)}
</span>
)}
{model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
Preview
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />} {value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div> </div>
</SelectItem> </SelectItem>
@@ -164,7 +271,7 @@ export function CopilotConfigForm() {
}; };
// Batch update for presets // Batch update for presets
const applyPreset = (preset: (typeof MODEL_PRESETS)[0]) => { const applyPreset = (preset: (typeof FREE_PRESETS)[0] | (typeof PAID_PRESETS)[0]) => {
setLocalOverrides((prev) => ({ setLocalOverrides((prev) => ({
...prev, ...prev,
model: preset.default, model: preset.default,
@@ -300,19 +407,65 @@ export function CopilotConfigForm() {
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings Apply pre-configured model mappings
</p> </p>
<div className="flex flex-wrap gap-2">
{MODEL_PRESETS.map((preset) => ( {/* Free Tier Presets */}
<Button <div className="mb-4">
key={preset.name} <div className="flex items-center gap-2 mb-2">
<Badge
variant="outline" variant="outline"
size="sm" className="text-[10px] bg-green-100 text-green-700 border-green-200"
className="text-xs h-7 gap-1"
onClick={() => applyPreset(preset)}
> >
<Zap className="w-3 h-3" /> Free Tier
{preset.name} </Badge>
</Button> <span className="text-[10px] text-muted-foreground">
))} No premium usage count
</span>
</div>
<div className="flex flex-wrap gap-2">
{FREE_PRESETS.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset(preset)}
title={preset.description}
>
<Zap className="w-3 h-3 text-green-600" />
{preset.name}
</Button>
))}
</div>
</div>
{/* Paid Tier Presets */}
<div>
<div className="flex items-center gap-2 mb-2">
<Badge
variant="outline"
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
>
Pro+ Required
</Badge>
<span className="text-[10px] text-muted-foreground">
Uses premium request quota
</span>
</div>
<div className="flex flex-wrap gap-2">
{PAID_PRESETS.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset(preset)}
title={preset.description}
>
<Zap className="w-3 h-3" />
{preset.name}
</Button>
))}
</div>
</div> </div>
</div> </div>
+9
View File
@@ -51,12 +51,21 @@ export interface CopilotConfig {
haiku_model?: string; haiku_model?: string;
} }
/** GitHub Copilot plan tiers */
export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise';
export interface CopilotModel { export interface CopilotModel {
id: string; id: string;
name: string; name: string;
provider: 'openai' | 'anthropic'; provider: 'openai' | 'anthropic';
isDefault?: boolean; isDefault?: boolean;
isCurrent?: boolean; isCurrent?: boolean;
/** Minimum plan tier required (free = available to all) */
minPlan?: CopilotPlanTier;
/** Premium request multiplier (0 = free, higher = more expensive) */
multiplier?: number;
/** Whether this model is in preview */
preview?: boolean;
} }
export interface CopilotRawSettings { export interface CopilotRawSettings {