From 123c1f32b57e0a1f975407177a7f8f67f57d2ef8 Mon Sep 17 00:00:00 2001 From: Chris Scott Date: Sun, 14 Dec 2025 13:42:48 -0500 Subject: [PATCH] Fix agent model config to use string format instead of object Fixes https://github.com/chriswritescode-dev/opencode-manager/issues/21 - Change agent model from object {modelID, providerID} to string format provider/model - Add reusable Combobox component for autocomplete inputs - Add provider and model autocomplete to agent dialog with filtering --- .../src/components/settings/AgentDialog.tsx | 95 +++++-- .../src/components/settings/AgentsEditor.tsx | 8 +- frontend/src/components/ui/combobox.tsx | 236 ++++++++++++++++++ 3 files changed, 312 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/ui/combobox.tsx diff --git a/frontend/src/components/settings/AgentDialog.tsx b/frontend/src/components/settings/AgentDialog.tsx index 6203382..5acfcae 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -1,6 +1,8 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -8,6 +10,8 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Combobox, type ComboboxOption } from '@/components/ui/combobox' +import { getProvidersWithModels } from '@/api/providers' const agentFormSchema = z.object({ name: z.string().min(1, 'Agent name is required').regex(/^[a-z0-9-]+$/, 'Must be lowercase letters, numbers, and hyphens only'), @@ -36,10 +40,8 @@ interface Agent { mode?: 'subagent' | 'primary' | 'all' temperature?: number topP?: number - model?: { - modelID: string - providerID: string - } + top_p?: number + model?: string tools?: Record permission?: { edit?: 'ask' | 'allow' | 'deny' @@ -50,6 +52,12 @@ interface Agent { [key: string]: unknown } +function parseModelString(model?: string): { providerId: string; modelId: string } { + if (!model) return { providerId: '', modelId: '' } + const [providerId, ...rest] = model.split('/') + return { providerId: providerId || '', modelId: rest.join('/') || '' } +} + interface AgentDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -58,6 +66,29 @@ interface AgentDialogProps { } export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: AgentDialogProps) { + const parsedModel = parseModelString(editingAgent?.agent.model) + + const { data: providers = [] } = useQuery({ + queryKey: ['providers-with-models'], + queryFn: () => getProvidersWithModels(), + enabled: open, + staleTime: 5 * 60 * 1000, + }) + + const providerOptions: ComboboxOption[] = useMemo(() => { + const sourceLabels: Record = { + configured: 'Custom', + local: 'Local', + builtin: 'Built-in', + } + return providers.map(p => ({ + value: p.id, + label: p.name || p.id, + description: p.models.length > 0 ? `${p.models.length} models` : undefined, + group: sourceLabels[p.source] || 'Other', + })) + }, [providers]) + const form = useForm({ resolver: zodResolver(agentFormSchema), defaultValues: { @@ -66,9 +97,9 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen prompt: editingAgent?.agent.prompt || '', mode: editingAgent?.agent.mode || 'subagent', temperature: editingAgent?.agent.temperature ?? 0.7, - topP: editingAgent?.agent.topP ?? 1, - modelId: editingAgent?.agent.model?.modelID || '', - providerId: editingAgent?.agent.model?.providerID || '', + topP: editingAgent?.agent.topP ?? editingAgent?.agent.top_p ?? 1, + modelId: parsedModel.modelId, + providerId: parsedModel.providerId, write: editingAgent?.agent.tools?.write ?? true, edit: editingAgent?.agent.tools?.edit ?? true, bash: editingAgent?.agent.tools?.bash ?? true, @@ -80,6 +111,23 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen } }) + const selectedProviderId = form.watch('providerId') + + const modelOptions: ComboboxOption[] = useMemo(() => { + const selectedProvider = providers.find(p => p.id === selectedProviderId) + if (selectedProvider && selectedProvider.models.length > 0) { + return selectedProvider.models.map(m => ({ + value: m.id, + label: m.name || m.id, + })) + } + return providers.flatMap(p => p.models.map(m => ({ + value: m.id, + label: m.name || m.id, + group: p.name || p.id, + }))) + }, [providers, selectedProviderId]) + const handleSubmit = (values: AgentFormValues) => { const agent: Agent = { prompt: values.prompt, @@ -101,11 +149,8 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen } } - if (values.modelId || values.providerId) { - agent.model = { - modelID: values.modelId || '', - providerID: values.providerId || '' - } + if (values.modelId && values.providerId) { + agent.model = `${values.providerId}/${values.modelId}` } onSubmit(values.name, agent) @@ -259,14 +304,17 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen
( - Model ID + Provider ID - @@ -276,14 +324,17 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen ( - Provider ID + Model ID - diff --git a/frontend/src/components/settings/AgentsEditor.tsx b/frontend/src/components/settings/AgentsEditor.tsx index 1cadee7..b39dcaf 100644 --- a/frontend/src/components/settings/AgentsEditor.tsx +++ b/frontend/src/components/settings/AgentsEditor.tsx @@ -11,10 +11,8 @@ interface Agent { mode?: 'subagent' | 'primary' | 'all' temperature?: number topP?: number - model?: { - modelID: string - providerID: string - } + top_p?: number + model?: string tools?: Record permission?: { edit?: 'ask' | 'allow' | 'deny' @@ -120,7 +118,7 @@ export function AgentsEditor({ agents, onChange }: AgentsEditorProps) {

Mode: {agent.mode}

{agent.temperature !== undefined &&

Temperature: {agent.temperature}

} {agent.topP !== undefined &&

Top P: {agent.topP}

} - {agent.model?.modelID &&

Model: {agent.model.providerID}/{agent.model.modelID}

} + {agent.model &&

Model: {agent.model}

} {agent.disable &&

Status: Disabled

}
{agent.prompt && ( diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx new file mode 100644 index 0000000..adc674d --- /dev/null +++ b/frontend/src/components/ui/combobox.tsx @@ -0,0 +1,236 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { cn } from '@/lib/utils' +import { ChevronDown } from 'lucide-react' + +export interface ComboboxOption { + value: string + label: string + description?: string + group?: string +} + +interface ComboboxProps { + value: string + onChange: (value: string) => void + options: ComboboxOption[] + placeholder?: string + disabled?: boolean + className?: string + allowCustomValue?: boolean +} + +export function Combobox({ + value, + onChange, + options, + placeholder = 'Select or type...', + disabled = false, + className, + allowCustomValue = true, +}: ComboboxProps) { + const [isOpen, setIsOpen] = useState(false) + const [inputValue, setInputValue] = useState(value) + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + setInputValue(value) + }, [value]) + + const filteredOptions = options.filter(option => + option.value.toLowerCase().includes(inputValue.toLowerCase()) || + option.label.toLowerCase().includes(inputValue.toLowerCase()) || + (option.description?.toLowerCase().includes(inputValue.toLowerCase())) + ) + + const groupedOptions = filteredOptions.reduce((acc, option) => { + const group = option.group || '' + if (!acc[group]) acc[group] = [] + acc[group].push(option) + return acc + }, {} as Record) + + const flatFilteredOptions = Object.values(groupedOptions).flat() + + useEffect(() => { + setSelectedIndex(0) + }, [inputValue]) + + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + if (allowCustomValue) { + onChange(inputValue) + } else if (!options.some(o => o.value === inputValue)) { + setInputValue(value) + } + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen, inputValue, value, onChange, options, allowCustomValue]) + + useEffect(() => { + if (!isOpen || !listRef.current) return + + const items = listRef.current.querySelectorAll('[data-option]') + const selectedItem = items[selectedIndex] as HTMLElement + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest' }) + } + }, [selectedIndex, isOpen]) + + const handleSelect = useCallback((optionValue: string) => { + setInputValue(optionValue) + onChange(optionValue) + setIsOpen(false) + inputRef.current?.blur() + }, [onChange]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + setIsOpen(true) + e.preventDefault() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, flatFilteredOptions.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (flatFilteredOptions[selectedIndex]) { + handleSelect(flatFilteredOptions[selectedIndex].value) + } else if (allowCustomValue && inputValue) { + handleSelect(inputValue) + } + break + case 'Escape': + e.preventDefault() + setIsOpen(false) + setInputValue(value) + break + case 'Tab': + setIsOpen(false) + if (allowCustomValue) { + onChange(inputValue) + } + break + } + }, [isOpen, selectedIndex, flatFilteredOptions, handleSelect, allowCustomValue, inputValue, value, onChange]) + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const newValue = e.target.value + setInputValue(newValue) + setIsOpen(true) + if (allowCustomValue) { + onChange(newValue) + } + }, [allowCustomValue, onChange]) + + const handleFocus = useCallback(() => { + setIsOpen(true) + }, []) + + let optionIndex = -1 + + return ( +
+
+ + +
+ + {isOpen && flatFilteredOptions.length > 0 && ( +
+ {Object.entries(groupedOptions).map(([group, groupOptions]) => ( +
+ {group && ( +
+ {group} +
+ )} + {groupOptions.map((option) => { + optionIndex++ + const currentIndex = optionIndex + const isSelected = currentIndex === selectedIndex + + return ( + + ) + })} +
+ ))} +
+ )} + + {isOpen && flatFilteredOptions.length === 0 && inputValue && allowCustomValue && ( +
+
+ Press Enter to use "{inputValue}" +
+
+ )} +
+ ) +}