Searchable Model Picker (ModelSearchSelect)

Problem

The image/video model dropdowns are very long. A user has no way to filter them. They want to type any of:

…and have the list narrow to matching models. The search must be available in both places a model is picked:

  1. the bottom-of-node dropdown (the quick toolbar on the node card), and
  2. the configuration-panel dropdown (right sidebar).

Decision

Build one reusable searchable picker<ModelSearchSelect> — and drop it in wherever a model is selected. The list it shows (and therefore what is matchable) is driven by the context it is opened in: an image node shows only image-gen models, an image-to-video node shows only i2v models, etc. This is the “depends where we open it” behavior — it falls out naturally because the picker is fed the same per-context *_MODELS array the dropdown already uses, and the search tokens are derived from each model’s own metadata.

Scope of this pass: the universal component, wired into the image + video editor selectors only. Audio / Suno / TTS / voice, the LLM picker, and the published-app (presentation) runner are deferred — they become near one-line swaps once the component exists. They are listed below so they are not forgotten.

Approach

Popover + cmdk Command combobox (Approach A), replacing the inner shadcn <Select>. Rationale:

Components

1. frontend/src/lib/model-search.ts (pure, no React → unit-testable)

The “brain.” Builds a search haystack per model and matches queries against it.

// Build one lowercased haystack string for a model id.
// Sources (all already used to render the real dropdowns, so search can never
// claim a capability the model doesn't actually offer in the UI):
//   - name:        label + value(id) + desc
//   - company:     getModel(id)?.family  -- with a base-id fallback when the
//                  exact id isn't in the catalog (veo3.1 / veo3_lite / grok /
//                  ltx-*): strip mode/tier suffixes (.1, _lite, _fast, -fast,
//                  -i2v, -t2v, -pro) and retry getModel(base), so "google"
//                  still matches every VEO variant. (from @nodaro/shared)
//   - aspect:      IMAGE_ASPECT_RATIOS[id] ?? VIDEO_ASPECT_RATIOS[id]   (values)
//   - resolution:  IMAGE_RESOLUTION_OPTIONS[id] ?? VIDEO_RESOLUTION_OPTIONS[id]
//   - duration:    VIDEO_DURATION_OPTIONS[id]      (formatted "8s")
// Memoized in a module-level Map keyed by id.
export function modelSearchHaystack(value: string, label: string, desc?: string): string

// Multi-token AND match, case-insensitive, substring per token.
// query "banana 4k"  -> haystack must contain "banana" AND "4k"
// Empty / whitespace query -> matches everything (returns true).
export function modelMatchesQuery(haystack: string, query: string): boolean

Matching rules:

2. frontend/src/components/editor/config-panels/model-search-select.tsx

type ModelOption = { value: string; label: string; desc?: string; tooltip?: string }

interface ModelSearchSelectProps {
  value: string
  onChange: (value: string) => void
  options: readonly ModelOption[]
  // Optional controlled-open plumbing so the canvas quick toolbars can keep
  // their openCount / deferred-close behavior. Falls back to internal state.
  open?: boolean
  onOpenChange?: (open: boolean) => void
  triggerClassName?: string   // reuse ghostTriggerClass / ghostPopoverTriggerClass
  contentClassName?: string   // e.g. "z-[9999]" — required for pickers mounted
                              // inside a modal (face generator) so the popover
                              // isn't occluded by the dialog
  align?: "start" | "center" | "end"
  ariaLabel?: string
  placeholder?: string        // search placeholder; sensible default
  triggerIcon?: ReactNode     // e.g. the Sparkles icon used in the toolbars
}

Renders:

3. ModelCommandItem (in the same file)

A fresh reimplementation of ModelSelectOption’s look — not a reuse of SelectItemWithMeta, which is a Radix Select.Item and throws outside a <Select> context. It renders a <CommandItem> that visually matches today:

Rollout — image + video editor selectors (this pass)

Surface File List Notes
Bottom toolbar nodes/generate-image-quick-toolbar.tsx IMAGE_GEN_MODELS default + compact modes; preserve handleOpenChange/openCount/deferred-close via open/onOpenChange props; keep the “N models” chip when multi-provider is active
Bottom toolbar nodes/generate-video-quick-toolbar.tsx VIDEO_GEN_MODELS default + compact; same open plumbing
Config (multi) config-panels/image-configs.tsx — GenerateImage IMAGE_GEN_MODELS refactor MultiProviderPicker to use ModelSearchSelect per row
Config config-panels/image-configs.tsx — ModifyImage MODIFY_IMAGE_MODELS inside MappableField
Config config-panels/video-configs.tsx — ImageToVideo VIDEO_I2V_MODELS  
Config config-panels/video-configs.tsx — TextToVideo VIDEO_T2V_MODELS  
Config config-panels/video-configs.tsx — GenerateVideo VIDEO_GEN_MODELS  
Config config-panels/video-configs.tsx — VideoToVideo VIDEO_V2V_MODELS  
Config config-panels/video-configs.tsx — MotionTransfer MOTION_TRANSFER_MODELS currently hardcoded SelectItems; the list’s values+labels match exactly, so the swap is behavior-preserving and adds badge/desc for free
Config config-panels/entity-configs.tsx — Face Generator IMAGE_GEN_MODELS inside a modal → pass contentClassName="z-[9999]" (matches today’s SelectContent)
Config config-panels/kling3-studio-config.tsx VIDEO_I2V_MODELS verify exact list/line during impl
Config config-panels/generative-configs.tsx — Generative Scene local IMAGE_MODEL_OPTIONS / VIDEO_MODEL_OPTIONS two selectors; each has an “Auto” sentinel first option (caller maps autoundefined) and plain rendering — pass Auto as a normal option with no metadata

MappableField wrappers are untouched — we only swap the inner <Select>.

MultiProviderPicker change

Only one production caller (generate-image). Keep the card-per-selected-provider layout and the “Add another model” / remove buttons. Replace each row’s inner <Select>…<SelectContent>{renderItems(p)}… with <ModelSearchSelect options={…} value={p} onChange={…} />. The renderItems prop collapses into a single options prop, but the per-row filtering it encodes must move into the component: each row shows current value + providers not already selected (today: options.filter(m => m.value === current || !selected.includes(m.value))). Preserve:

Update the existing tests __tests__/multi-provider-picker.test.tsx and the MultiProviderPicker mock in __tests__/provider-snap.test.tsx to the new API.

Bottom-toolbar open/close plumbing

The quick toolbars track openCount and defer close by one macrotask (setTimeout(…, 0)) so the toolbar doesn’t unpin mid-pick. ModelSearchSelect exposes open / onOpenChange, so the toolbar passes its existing handleOpenChange unchanged. Ghost styling is passed via triggerClassName (ghostTriggerClass default mode, ghostPopoverTriggerClass compact mode), and the Sparkles prefix via triggerIcon.

Deferred (component is universal → later one-line swaps)

Drift resistance

Search tokens are derived from the same MODEL_CATALOG (getModel) and per-provider option maps (IMAGE_ASPECT_RATIOS, VIDEO_RESOLUTION_OPTIONS, VIDEO_DURATION_OPTIONS, …) that already drive the dropdowns. Adding a new model makes it searchable by its real attributes automatically — there is no separate search registry to keep in sync.

Testing

Verified against code (audit, 2026-05-31)

Out of scope

Edge cases / notes