feat(ui): rewrite profile create dialog with provider presets

- add preset cards grid (OpenRouter, GLM, GLMT, Kimi, Custom)
- add model search picker for OpenRouter with newest-first sorting
- auto-fill form fields when preset selected
- show API key hints from preset config
- skip model prompts for preset profiles
This commit is contained in:
kaitranntt
2025-12-20 19:48:35 -05:00
parent 9c90e1dc2c
commit adcc3235f0
@@ -1,17 +1,17 @@
/**
* Profile Create Dialog Component
* Modal dialog with tabbed interface for creating new API profiles
* Includes Quick Start templates and advanced model configuration
* Modal dialog with provider preset cards and model configuration
*/
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
@@ -23,11 +23,19 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
import { PROVIDER_PRESETS, type ProviderPreset } from '@/lib/provider-presets';
import {
searchModels,
formatPricingPair,
formatContextLength,
formatModelAge,
getNewestModelsPerProvider,
} from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types';
const schema = z.object({
name: z
@@ -48,6 +56,7 @@ interface ProfileCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (name: string) => void;
initialMode?: 'normal' | 'openrouter';
}
// Common URL mistakes to warn about
@@ -58,6 +67,11 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<string | null>('openrouter');
const [modelSearch, setModelSearch] = useState('');
// OpenRouter models for model picker
const { models: openRouterModels } = useOpenRouterCatalog();
const {
register,
@@ -65,6 +79,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
formState: { errors },
control,
reset,
setValue,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
@@ -80,21 +95,73 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
// Reset form when dialog opens
// Get current preset config
const currentPreset = useMemo(() => {
if (!selectedPreset || selectedPreset === 'custom') return null;
return PROVIDER_PRESETS.find((p) => p.id === selectedPreset);
}, [selectedPreset]);
// Filter models for OpenRouter search (newest first)
const filteredModels = useMemo(() => {
if (!modelSearch.trim()) {
// Show newest models when no search
return getNewestModelsPerProvider(openRouterModels, 2);
}
// Search and sort by created date (newest first)
const results = searchModels(openRouterModels, modelSearch);
return [...results].sort((a, b) => (b.created ?? 0) - (a.created ?? 0)).slice(0, 20);
}, [openRouterModels, modelSearch]);
// Reset form when dialog opens
useEffect(() => {
if (open) {
reset();
setActiveTab('basic');
setUrlWarning(null);
setShowApiKey(false);
setSelectedPreset('openrouter');
setModelSearch('');
// Pre-fill with OpenRouter preset
const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
if (openrouterPreset) {
setTimeout(() => {
setValue('name', openrouterPreset.defaultProfileName);
setValue('baseUrl', openrouterPreset.baseUrl);
}, 0);
}
}
}, [open, reset]);
}, [open, reset, setValue]);
// Handle preset selection
const handlePresetSelect = (presetId: string) => {
setSelectedPreset(presetId);
const preset = PROVIDER_PRESETS.find((p) => p.id === presetId);
if (preset) {
setValue('name', preset.defaultProfileName);
setValue('baseUrl', preset.baseUrl);
if (preset.defaultModel) {
setValue('model', preset.defaultModel);
setValue('opusModel', preset.defaultModel);
setValue('sonnetModel', preset.defaultModel);
setValue('haikuModel', preset.defaultModel);
}
} else {
// Custom
setValue('name', '');
setValue('baseUrl', '');
setValue('model', '');
}
};
// Handle model selection from picker
const handleModelSelect = (model: CategorizedModel) => {
setValue('model', model.id);
setModelSearch(model.name);
};
// Check for common URL mistakes
useEffect(() => {
if (baseUrlValue) {
if (baseUrlValue && selectedPreset === 'custom') {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
if (lowerUrl.endsWith(path)) {
@@ -107,13 +174,18 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
}
}
setUrlWarning(null);
}, [baseUrlValue]);
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
const preset = currentPreset;
const finalData = {
...data,
baseUrl: preset ? preset.baseUrl : data.baseUrl,
};
try {
await createMutation.mutateAsync(data);
toast.success(`Profile "${data.name}" created`);
onSuccess(data.name);
await createMutation.mutateAsync(finalData);
toast.success(`Profile "${finalData.name}" created`);
onSuccess(finalData.name);
onOpenChange(false);
} catch (error) {
toast.error((error as Error).message || 'Failed to create profile');
@@ -124,19 +196,56 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
const isOpenRouter = selectedPreset === 'openrouter';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0 overflow-hidden">
<DialogContent className="sm:max-w-[700px] p-0 gap-0 overflow-hidden max-h-[90vh]">
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
Create API Profile
</DialogTitle>
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
<DialogDescription>
Choose a provider or configure a custom API endpoint.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
{/* Provider Preset Cards */}
<div className="px-6 py-4 border-b bg-muted/30">
<Label className="text-xs text-muted-foreground mb-2 block">Choose a provider</Label>
<div className="grid grid-cols-5 gap-2">
{PROVIDER_PRESETS.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect('custom')}
className={cn(
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
selectedPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-muted-foreground/30'
)}
>
<Settings2 className="w-5 h-5 text-muted-foreground" />
<span className="text-xs font-medium">Custom</span>
</button>
</div>
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
@@ -154,33 +263,31 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</TabsList>
</div>
<div className="flex-1 overflow-y-auto max-h-[60vh]">
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p>
)}
</div>
<ScrollArea className="flex-1">
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
{/* Profile Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
</p>
)}
</div>
{/* Base URL */}
{/* Base URL - only show for custom */}
{selectedPreset === 'custom' ? (
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
@@ -203,52 +310,108 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">Toggle API key visibility</span>
</Button>
) : (
currentPreset && (
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-md border">
{currentPreset.icon ? (
<img src={currentPreset.icon} alt="" className="w-5 h-5" />
) : (
<Settings2 className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1">
<p className="text-sm font-medium">{currentPreset.name} API</p>
<p className="text-xs text-muted-foreground">{currentPreset.baseUrl}</p>
</div>
</div>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
)
)}
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-6">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<TabsContent value="models" className="p-6 mt-0 space-y-4">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
to the specific models supported by your API provider.
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
provider.
</p>
</div>
</div>
<div className="space-y-5">
{/* OpenRouter Model Picker */}
{isOpenRouter && (
<div className="space-y-2">
<Label>Search Models</Label>
<Input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
/>
<div className="border rounded-md max-h-48 overflow-y-auto">
{filteredModels.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{modelSearch
? `No models found for "${modelSearch}"`
: 'Loading models...'}
</p>
) : (
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-orange-500" />
<span>Newest Models</span>
</div>
)}
{filteredModels.map((model) => (
<ModelSearchItem
key={model.id}
model={model}
onClick={() => handleModelSelect(model)}
showAge={!modelSearch}
/>
))}
</div>
)}
</div>
</div>
)}
{/* Model Inputs */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
@@ -259,18 +422,15 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Fallback model if no specific tier is requested
</p>
</div>
<div className="grid gap-4 pt-2 border-t">
<div className="grid gap-3 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping (Primary)
Sonnet Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
@@ -278,14 +438,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
placeholder="e.g. gpt-4o, claude-sonnet-4"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping (Complex Tasks)
Opus Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
@@ -293,14 +453,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1-preview, claude-3-opus"
placeholder="e.g. o1, claude-opus-4.5"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping (Fast Tasks)
Haiku Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
@@ -308,16 +468,16 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3-haiku"
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
className="font-mono text-sm h-9"
/>
</div>
</div>
</div>
</TabsContent>
</div>
</ScrollArea>
<DialogFooter className="p-6 pt-2 border-t bg-muted/10">
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
@@ -345,3 +505,79 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</Dialog>
);
}
/** Preset card component */
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ProviderPreset;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
isSelected
? preset.featured
? 'border-orange-500 bg-orange-50 dark:bg-orange-950/20'
: 'border-primary bg-primary/5'
: 'border-muted hover:border-muted-foreground/30'
)}
>
{preset.icon ? (
<img src={preset.icon} alt="" className="w-5 h-5" />
) : (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
{preset.name.charAt(0)}
</div>
)}
<span className="text-xs font-medium">{preset.name}</span>
{preset.badge && (
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{preset.badge}
</Badge>
)}
</button>
);
}
/** Model search result item */
function ModelSearchItem({
model,
onClick,
showAge,
}: {
model: CategorizedModel;
onClick: () => void;
showAge?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
>
<span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && (
<Badge variant="outline" className="text-[10px] text-orange-600">
{formatModelAge(model.created)}
</Badge>
)}
{model.isFree ? (
<Badge variant="secondary" className="text-xs">
Free
</Badge>
) : (
<span>{formatPricingPair(model.pricing)}</span>
)}
<span>{formatContextLength(model.context_length)}</span>
</span>
</button>
);
}