fix: pin picker search input to top and connect providers in a single click

Bug 1: the composer picker search input lived inside the scrolling dropdown
body as a sticky element, so container padding and Radix's open-focus
scroll left it slightly below the top. Moved it out of the scroll area into
a fixed header with the model list in an inner scroller, so it's flush at
the top and always visible.

Bug 2: Connect gated on resolvedModel, which is empty while the debounced
key-change fetch is still in flight right after pasting a key, so the first
click was a no-op. Connect now gates on credentials only, and the save
handler resolves the model on demand (one fetch, same silent precedence)
when it hasn't loaded yet. Save payload, models:test gate, and per-function
fields unchanged.
This commit is contained in:
Prakhar Pandey 2026-07-14 12:38:00 +05:30
parent 1a8367ada4
commit e74ce4e9a5
2 changed files with 40 additions and 5 deletions

View file

@ -1532,12 +1532,17 @@ function ChatInputInner({
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
{modelGroups.length === 0 && !standaloneDefault ? (
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
) : (
<>
<div className="sticky top-0 z-10 -mx-1 -mt-1 bg-popover p-1">
{/* Fixed search header lives OUTSIDE the scroll area (the
inner div below scrolls), so it's flush at the very top
and always visible without any scroll. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
@ -1553,6 +1558,7 @@ function ChatInputInner({
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
onValueChange={handleModelChange}
@ -1609,6 +1615,7 @@ function ChatInputInner({
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)}
</DropdownMenuRadioGroup>
</div>
</>
)}
</DropdownMenuContent>

View file

@ -414,8 +414,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
}
return saved
})()
// Gate Connect on credentials only, NOT on resolvedModel — that derives
// from the live fetch, which hasn't settled the instant a key is pasted, so
// gating on it made the first click a no-op (the model is resolved, fetching
// on demand if needed, inside handleTestAndSave).
const canTest =
resolvedModel.length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
@ -567,10 +570,35 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
if (!canTest) return
setTestState({ status: "testing" })
try {
// Normally providerModels has loaded by click time and resolvedModel is
// set. But a Connect click right after pasting a key can beat the
// debounced key-change fetch — so when nothing is resolved yet, fetch
// the list on demand (one fetch, then save) instead of forcing a second
// click. Same silent precedence as resolvedModel, minus the customModel
// branch (that path only runs when resolvedModel is already empty).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
})
if (listRes.success && listRes.models && listRes.models.length > 0) {
const preferred = preferredDefaults[provider]
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
}
}
if (!model) {
setTestState({ status: "error", error: "Enter a model to connect" })
toast.error("Enter a model to connect")
return
}
// The silently resolved model takes the primary slot; the rest of the
// saved list is preserved (same semantics setPrimaryModel had).
const existing = activeConfig.models.map(m => m.trim())
const allModels = [resolvedModel, ...existing.slice(1).filter(m => m && m !== resolvedModel)]
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
const providerConfig = {
provider: {
flavor: provider,