feat(ui): add streamlined OpenRouter profile editor

- redesign friendly-ui-section with OpenRouter-specific view

- add model selection, tier mapping, API key sections

- compact ModelItem layout for better space efficiency

- add onEnvBulkChange prop for atomic env updates
This commit is contained in:
kaitranntt committed 2025-12-20 23:11:15 -05:00
1 parent 4f4ab43eb3
commit 7788137f1c
4 files changed
+218 -72

No files matched your search

@@ -1,16 +1,22 @@
/** /**
* Friendly UI Section * Friendly UI Section
* Left column with environment variables and info tabs * Left column with environment variables and info tabs
* Enhanced with OpenRouter model picker when applicable * Enhanced with OpenRouter-specific streamlined UI when applicable
*/ */
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { EnvEditorSection } from './env-editor-section'; import { EnvEditorSection } from './env-editor-section';
import { InfoSection } from './info-section'; import { InfoSection } from './info-section';
import { OpenRouterModelPicker } from '@/components/profiles/openrouter-model-picker'; import { OpenRouterModelPicker } from '@/components/profiles/openrouter-model-picker';
import { ModelTierMapping, type TierMapping } from '@/components/profiles/model-tier-mapping'; import { ModelTierMapping, type TierMapping } from '@/components/profiles/model-tier-mapping';
import { Label } from '@/components/ui/label';
import { MaskedInput } from '@/components/ui/masked-input';
import { ChevronRight, Settings2 } from 'lucide-react';
import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils'; import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { Settings, SettingsResponse } from './types'; import type { Settings, SettingsResponse } from './types';
interface FriendlyUISectionProps { interface FriendlyUISectionProps {
@@ -46,9 +52,27 @@ export function FriendlyUISection({
// Memoize currentEnv for consistent reference // Memoize currentEnv for consistent reference
const currentEnv = settingsEnv ?? {}; const currentEnv = settingsEnv ?? {};
// Handle model selection from OpenRouter picker // Handle model selection from OpenRouter picker - applies to ALL tiers
const handleModelChange = (modelId: string) => { const handleModelChange = (modelId: string) => {
onEnvValueChange('ANTHROPIC_MODEL', modelId); if (onEnvBulkChange) {
// Update all 4 model tiers at once
const newEnv = {
...currentEnv,
ANTHROPIC_MODEL: modelId,
ANTHROPIC_DEFAULT_OPUS_MODEL: modelId,
ANTHROPIC_DEFAULT_SONNET_MODEL: modelId,
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelId,
};
onEnvBulkChange(newEnv);
} else {
// Fallback: update one by one
onEnvValueChange('ANTHROPIC_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_OPUS_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_SONNET_MODEL', modelId);
onEnvValueChange('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId);
}
// Show feedback toast
toast.success('Applied model to all tiers', { duration: 2000 });
}; };
// Handle tier mapping change // Handle tier mapping change
@@ -71,13 +95,30 @@ export function FriendlyUISection({
} }
}; };
// State for collapsible sections
const [showAllEnvVars, setShowAllEnvVars] = useState(false);
// For OpenRouter: filter out model-related env vars from the main display
// These are managed by the model picker and tier mapping
const openRouterManagedKeys = new Set([
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
]);
// Count of hidden env vars for OpenRouter profiles
const hiddenEnvVarCount = isOpenRouter
? Object.keys(currentEnv).filter((k) => openRouterManagedKeys.has(k)).length
: 0;
return ( return (
<div className="h-full flex flex-col"> <div className="h-full w-full min-w-0 flex flex-col">
<Tabs defaultValue="env" className="h-full flex flex-col"> <Tabs defaultValue="env" className="h-full w-full min-w-0 flex flex-col">
<div className="px-4 pt-4 shrink-0"> <div className="px-4 pt-4 shrink-0">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="env" className="flex-1"> <TabsTrigger value="env" className="flex-1">
Environment Variables {isOpenRouter ? 'Configuration' : 'Environment Variables'}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="info" className="flex-1"> <TabsTrigger value="info" className="flex-1">
Info & Usage Info & Usage
@@ -85,39 +126,114 @@ export function FriendlyUISection({
</TabsList> </TabsList>
</div> </div>
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col min-w-0">
<TabsContent <TabsContent
value="env" value="env"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden" className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden min-w-0"
> >
{/* OpenRouter Model Picker Section */} {/* OpenRouter Streamlined View */}
{isOpenRouter && ( {isOpenRouter ? (
<div className="px-4 pt-4 pb-2 border-b space-y-3 shrink-0"> <div className="flex-1 overflow-hidden">
<div className="space-y-2"> <div className="h-full overflow-y-auto overflow-x-hidden p-4 space-y-6">
<label className="text-xs font-medium text-muted-foreground"> {/* Model Selection - Primary Focus */}
Model (OpenRouter) <div className="space-y-3">
</label> <Label className="text-sm font-medium">Model Selection</Label>
<OpenRouterModelPicker <OpenRouterModelPicker
value={currentEnv.ANTHROPIC_MODEL} value={currentEnv.ANTHROPIC_MODEL}
onChange={handleModelChange} onChange={handleModelChange}
placeholder="Search OpenRouter models..." placeholder="Search OpenRouter models..."
/> />
</div> </div>
<ModelTierMapping
selectedModel={currentEnv.ANTHROPIC_MODEL}
value={tierMapping}
onChange={handleTierMappingChange}
/>
</div>
)}
<EnvEditorSection {/* Model Tier Mapping - Collapsible */}
currentSettings={currentSettings} <ModelTierMapping
newEnvKey={newEnvKey} selectedModel={currentEnv.ANTHROPIC_MODEL}
onNewEnvKeyChange={onNewEnvKeyChange} value={tierMapping}
onEnvValueChange={onEnvValueChange} onChange={handleTierMappingChange}
onAddEnvVar={onAddEnvVar} />
/>
{/* API Key - Simplified */}
<div className="space-y-2">
<Label className="text-sm font-medium">API Key</Label>
<MaskedInput
value={currentEnv.ANTHROPIC_AUTH_TOKEN || ''}
onChange={(e) => onEnvValueChange('ANTHROPIC_AUTH_TOKEN', e.target.value)}
placeholder="sk-or-v1-..."
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Get your API key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
openrouter.ai/keys
</a>
</p>
</div>
{/* Advanced: All Environment Variables */}
<Collapsible open={showAllEnvVars} onOpenChange={setShowAllEnvVars}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight
className={cn(
'h-4 w-4 transition-transform',
showAllEnvVars && 'rotate-90'
)}
/>
<Settings2 className="h-4 w-4" />
<span>All Environment Variables</span>
<span className="text-xs font-normal opacity-70">
({Object.keys(currentEnv).length} vars
{hiddenEnvVarCount > 0 && `, ${hiddenEnvVarCount} managed by picker`})
</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="space-y-3 border rounded-lg p-3 bg-muted/30">
{Object.entries(currentEnv).map(([key, value]) => (
<div key={key} className="space-y-1">
<Label className="text-xs text-muted-foreground flex items-center gap-2">
{key}
{openRouterManagedKeys.has(key) && (
<span className="text-[10px] px-1.5 py-0.5 bg-primary/10 text-primary rounded">
managed
</span>
)}
</Label>
{key === 'ANTHROPIC_AUTH_TOKEN' ? (
<MaskedInput
value={value}
onChange={(e) => onEnvValueChange(key, e.target.value)}
className="font-mono text-xs h-8"
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onEnvValueChange(key, e.target.value)}
className="w-full font-mono text-xs h-8 px-2 rounded border bg-background"
readOnly={openRouterManagedKeys.has(key)}
/>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
) : (
/* Standard Env Editor for non-OpenRouter profiles */
<EnvEditorSection
currentSettings={currentSettings}
newEnvKey={newEnvKey}
onNewEnvKeyChange={onNewEnvKeyChange}
onEnvValueChange={onEnvValueChange}
onAddEnvVar={onAddEnvVar}
/>
)}
</TabsContent> </TabsContent>
<TabsContent <TabsContent
+1 -1
View File
@@ -166,7 +166,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
</div> </div>
) : ( ) : (
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden"> <div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex flex-col overflow-hidden bg-muted/5"> <div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
<FriendlyUISection <FriendlyUISection
profileName={profileName} profileName={profileName}
data={data} data={data}
@@ -91,7 +91,7 @@ export function OpenRouterModelPicker({
} }
return ( return (
<div className={cn('space-y-2', className)}> <div className={cn('space-y-2 w-full min-w-0 overflow-hidden', className)}>
{/* Search Header */} {/* Search Header */}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -155,7 +155,7 @@ export function OpenRouterModelPicker({
)} )}
{/* Model List */} {/* Model List */}
<ScrollArea className="h-64 rounded-md border"> <ScrollArea className="h-72 w-full rounded-md border">
{isError ? ( {isError ? (
<div className="text-destructive p-4 text-center"> <div className="text-destructive p-4 text-center">
Failed to load models.{' '} Failed to load models.{' '}
@@ -168,23 +168,25 @@ export function OpenRouterModelPicker({
No models found matching &quot;{search}&quot; No models found matching &quot;{search}&quot;
</div> </div>
) : ( ) : (
<div className="space-y-4 p-2"> <div className="space-y-6 p-3">
{/* Newest Models Section (shown when no search) */} {/* Newest Models Section (shown when no search) */}
{showPresets && newestModels.length > 0 && ( {showPresets && newestModels.length > 0 && (
<div> <div>
<div className="text-muted-foreground bg-background sticky top-0 mb-1 flex items-center gap-1.5 py-1 text-xs font-semibold"> <div className="text-muted-foreground bg-background sticky top-0 mb-2 flex items-center gap-1.5 py-1.5 text-xs font-semibold border-b pb-2">
<Sparkles className="h-3 w-3 text-accent" /> <Sparkles className="h-3 w-3 text-accent" />
<span>Newest Models</span> <span>Newest Models</span>
</div> </div>
{newestModels.map((model) => ( <div className="space-y-1">
<ModelItem {newestModels.map((model) => (
key={model.id} <ModelItem
model={model} key={model.id}
isSelected={model.id === value} model={model}
onClick={() => onChange(model.id)} isSelected={model.id === value}
showAge onClick={() => onChange(model.id)}
/> showAge
))} />
))}
</div>
</div> </div>
)} )}
@@ -195,17 +197,19 @@ export function OpenRouterModelPicker({
return ( return (
<div key={category}> <div key={category}>
<div className="text-muted-foreground bg-background sticky top-0 mb-1 py-1 text-xs font-semibold"> <div className="text-muted-foreground bg-background sticky top-0 mb-2 py-1.5 text-xs font-semibold border-b pb-2">
{CATEGORY_LABELS[category]} {CATEGORY_LABELS[category]}
</div> </div>
{categoryModels.map((model) => ( <div className="space-y-1">
<ModelItem {categoryModels.map((model) => (
key={model.id} <ModelItem
model={model} key={model.id}
isSelected={model.id === value} model={model}
onClick={() => onChange(model.id)} isSelected={model.id === value}
/> onClick={() => onChange(model.id)}
))} />
))}
</div>
</div> </div>
); );
})} })}
@@ -232,39 +236,49 @@ function ModelItem({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm transition-colors', 'group flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground', 'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground' isSelected && 'bg-accent text-accent-foreground'
)} )}
> >
<span className="flex-1 truncate">{model.name}</span> <span className="flex-1 min-w-0 truncate font-medium">{model.name}</span>
<span <span
className={cn( className={cn(
'ml-2 flex items-center gap-2 text-xs', 'flex shrink-0 items-center gap-1 text-xs whitespace-nowrap',
isSelected ? 'text-accent-foreground/80' : 'text-muted-foreground' isSelected
? 'text-accent-foreground/80'
: 'text-muted-foreground group-hover:text-accent-foreground/80'
)} )}
> >
{showAge && model.created && ( {showAge && model.created && (
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
'text-[10px]', 'text-[10px] px-1',
isSelected isSelected
? 'border-accent-foreground/30 text-accent-foreground/80' ? 'border-accent-foreground/30 text-accent-foreground/80'
: 'text-accent border-accent/30' : 'text-accent border-accent/30 group-hover:text-accent-foreground/80 group-hover:border-accent-foreground/30'
)} )}
> >
{formatModelAge(model.created)} {formatModelAge(model.created)}
</Badge> </Badge>
)} )}
{model.isFree ? ( {model.isFree ? (
<Badge variant="secondary" className="text-xs"> <Badge
variant="secondary"
className={cn(
'text-[10px] px-1',
isSelected
? 'bg-accent-foreground/20 text-accent-foreground'
: 'group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground'
)}
>
Free Free
</Badge> </Badge>
) : ( ) : (
<span>{formatPricingPair(model.pricing)}</span> <span className="tabular-nums">{formatPricingPair(model.pricing)}</span>
)} )}
<span>{formatContextLength(model.context_length)}</span> <span className="tabular-nums">{formatContextLength(model.context_length)}</span>
</span> </span>
</button> </button>
); );
@@ -164,6 +164,10 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
setValue('sonnetModel', model.id); setValue('sonnetModel', model.id);
setValue('haikuModel', model.id); setValue('haikuModel', model.id);
setModelSearch(model.name); setModelSearch(model.name);
// Show feedback that model was applied to all tiers
toast.success(`Applied "${model.name}" to all model tiers`, {
duration: 2000,
});
}; };
// Check for common URL mistakes - only for truly custom URLs // Check for common URL mistakes - only for truly custom URLs
@@ -401,6 +405,12 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
value={modelSearch} value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)} onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..." placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredModels.length > 0) {
e.preventDefault();
handleModelSelect(filteredModels[0]);
}
}}
/> />
<div className="border rounded-md max-h-48 overflow-y-auto"> <div className="border rounded-md max-h-48 overflow-y-auto">
{filteredModels.length === 0 ? ( {filteredModels.length === 0 ? (
@@ -581,17 +591,23 @@ function ModelSearchItem({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent" className="group flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
> >
<span className="flex-1 truncate">{model.name}</span> <span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs"> <span className="text-muted-foreground group-hover:text-accent-foreground/80 ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && ( {showAge && model.created && (
<Badge variant="outline" className="text-[10px] text-accent"> <Badge
variant="outline"
className="text-[10px] text-accent group-hover:text-accent-foreground/80 group-hover:border-accent-foreground/30"
>
{formatModelAge(model.created)} {formatModelAge(model.created)}
</Badge> </Badge>
)} )}
{model.isFree ? ( {model.isFree ? (
<Badge variant="secondary" className="text-xs"> <Badge
variant="secondary"
className="text-xs group-hover:bg-accent-foreground/20 group-hover:text-accent-foreground"
>
Free Free
</Badge> </Badge>
) : ( ) : (