164 lines
5.9 KiB
TypeScript
164 lines
5.9 KiB
TypeScript
|
|
import { useState, useRef, useEffect } from 'react';
|
||
|
|
import { ChevronDown, Globe } from 'lucide-react';
|
||
|
|
import type { LLMProvider } from '../../../../types/app';
|
||
|
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||
|
|
|
||
|
|
type ModelOption = { value: string; label: string };
|
||
|
|
|
||
|
|
interface ModelSelectorBarProps {
|
||
|
|
provider: LLMProvider;
|
||
|
|
claudeModel: string;
|
||
|
|
setClaudeModel: (model: string) => void;
|
||
|
|
cursorModel: string;
|
||
|
|
setCursorModel: (model: string) => void;
|
||
|
|
codexModel: string;
|
||
|
|
setCodexModel: (model: string) => void;
|
||
|
|
geminiModel: string;
|
||
|
|
setGeminiModel: (model: string) => void;
|
||
|
|
claudeModelOptions: ModelOption[];
|
||
|
|
codexModelOptions: ModelOption[];
|
||
|
|
geminiModelOptions: ModelOption[];
|
||
|
|
cursorModelOptions: ModelOption[];
|
||
|
|
}
|
||
|
|
|
||
|
|
function useCurrentModel(
|
||
|
|
provider: LLMProvider,
|
||
|
|
claudeModel: string,
|
||
|
|
cursorModel: string,
|
||
|
|
codexModel: string,
|
||
|
|
geminiModel: string,
|
||
|
|
claudeModelOptions: ModelOption[],
|
||
|
|
codexModelOptions: ModelOption[],
|
||
|
|
geminiModelOptions: ModelOption[],
|
||
|
|
cursorModelOptions: ModelOption[],
|
||
|
|
): { value: string; label: string; options: ModelOption[] } {
|
||
|
|
const map: Record<LLMProvider, { value: string; options: ModelOption[] }> = {
|
||
|
|
claude: { value: claudeModel, options: claudeModelOptions },
|
||
|
|
cursor: { value: cursorModel, options: cursorModelOptions },
|
||
|
|
codex: { value: codexModel, options: codexModelOptions },
|
||
|
|
gemini: { value: geminiModel, options: geminiModelOptions },
|
||
|
|
};
|
||
|
|
const { value, options } = map[provider] ?? map.claude;
|
||
|
|
const label = options.find((o) => o.value === value)?.label ?? value;
|
||
|
|
return { value, label, options };
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ModelSelectorBar({
|
||
|
|
provider,
|
||
|
|
claudeModel,
|
||
|
|
setClaudeModel,
|
||
|
|
cursorModel,
|
||
|
|
setCursorModel,
|
||
|
|
codexModel,
|
||
|
|
setCodexModel,
|
||
|
|
geminiModel,
|
||
|
|
setGeminiModel,
|
||
|
|
claudeModelOptions,
|
||
|
|
codexModelOptions,
|
||
|
|
geminiModelOptions,
|
||
|
|
cursorModelOptions,
|
||
|
|
}: ModelSelectorBarProps) {
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const { value, label, options } = useCurrentModel(
|
||
|
|
provider, claudeModel, cursorModel, codexModel, geminiModel,
|
||
|
|
claudeModelOptions, codexModelOptions, geminiModelOptions, cursorModelOptions,
|
||
|
|
);
|
||
|
|
|
||
|
|
const filtered = search.trim()
|
||
|
|
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||
|
|
: options;
|
||
|
|
|
||
|
|
// Close on outside click
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
const handler = (e: MouseEvent) => {
|
||
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||
|
|
setOpen(false);
|
||
|
|
setSearch('');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
document.addEventListener('mousedown', handler);
|
||
|
|
return () => document.removeEventListener('mousedown', handler);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) setTimeout(() => searchRef.current?.focus(), 50);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
const handleSelect = (modelValue: string) => {
|
||
|
|
const setters: Record<LLMProvider, (v: string) => void> = {
|
||
|
|
claude: setClaudeModel,
|
||
|
|
cursor: setCursorModel,
|
||
|
|
codex: setCodexModel,
|
||
|
|
gemini: setGeminiModel,
|
||
|
|
};
|
||
|
|
setters[provider]?.(modelValue);
|
||
|
|
localStorage.setItem(`${provider}-model`, modelValue);
|
||
|
|
setOpen(false);
|
||
|
|
setSearch('');
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex-shrink-0 flex items-center justify-between px-4 py-1.5 border-b border-border/40 bg-muted/20 backdrop-blur-sm">
|
||
|
|
{/* Left: provider label */}
|
||
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
|
|
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||
|
|
<span className="capitalize">{provider}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Right: model picker */}
|
||
|
|
<div className="relative" ref={dropdownRef}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setOpen((o) => !o)}
|
||
|
|
className="flex items-center gap-1.5 rounded-md border border-border/50 bg-background/60 px-2.5 py-1 text-xs text-foreground hover:bg-accent hover:border-border transition-all"
|
||
|
|
>
|
||
|
|
<Globe className="h-3 w-3 text-muted-foreground" />
|
||
|
|
<span className="max-w-[180px] truncate">{label}</span>
|
||
|
|
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{open && (
|
||
|
|
<div className="absolute right-0 top-full mt-1.5 z-50 w-64 rounded-xl border border-border/60 bg-card shadow-xl overflow-hidden">
|
||
|
|
<div className="p-2 border-b border-border/40">
|
||
|
|
<input
|
||
|
|
ref={searchRef}
|
||
|
|
type="text"
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
placeholder="Search models..."
|
||
|
|
className="w-full rounded-lg bg-muted/50 px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none border border-border/40 focus:border-border"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="max-h-64 overflow-y-auto py-1">
|
||
|
|
{filtered.length === 0 ? (
|
||
|
|
<p className="px-3 py-2 text-xs text-muted-foreground">No models found</p>
|
||
|
|
) : (
|
||
|
|
filtered.map((opt) => (
|
||
|
|
<button
|
||
|
|
key={opt.value}
|
||
|
|
type="button"
|
||
|
|
onClick={() => handleSelect(opt.value)}
|
||
|
|
className={`w-full text-left px-3 py-1.5 text-xs transition-colors hover:bg-accent/60 ${
|
||
|
|
opt.value === value ? 'text-primary font-medium' : 'text-foreground'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<span className="truncate block">{opt.label}</span>
|
||
|
|
{opt.value === value && (
|
||
|
|
<span className="text-[10px] text-muted-foreground block">{opt.value}</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|