From b637f0287d2c849e3f0828097d1b6b829ed5f8da Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 30 May 2026 10:00:54 -0400 Subject: [PATCH] feat: floating model selector bar above messages (Option B) --- .../view/subcomponents/ModelSelectorBar.tsx | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/components/chat/view/subcomponents/ModelSelectorBar.tsx diff --git a/src/components/chat/view/subcomponents/ModelSelectorBar.tsx b/src/components/chat/view/subcomponents/ModelSelectorBar.tsx new file mode 100644 index 0000000..838d4cd --- /dev/null +++ b/src/components/chat/view/subcomponents/ModelSelectorBar.tsx @@ -0,0 +1,163 @@ +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 = { + 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(null); + const searchRef = useRef(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 void> = { + claude: setClaudeModel, + cursor: setCursorModel, + codex: setCodexModel, + gemini: setGeminiModel, + }; + setters[provider]?.(modelValue); + localStorage.setItem(`${provider}-model`, modelValue); + setOpen(false); + setSearch(''); + }; + + return ( +
+ {/* Left: provider label */} +
+ + {provider} +
+ + {/* Right: model picker */} +
+ + + {open && ( +
+
+ 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" + /> +
+
+ {filtered.length === 0 ? ( +

No models found

+ ) : ( + filtered.map((opt) => ( + + )) + )} +
+
+ )} +
+
+ ); +}