From dab9c14eb62d38a3214829cd1398d1f146c0c970 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 6 Jul 2026 13:24:55 -0400 Subject: [PATCH 1/5] feat(desktop): add typeahead search to persona model dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model picker can show dozens of entries when a provider (e.g. Databricks) supports model discovery. Introduce PersonaModelCombobox — a Popover + search input + keyboard navigation — used only at the model call site. The runtime and provider pickers stay on PersonaDropdownField unchanged. PersonaModelCombobox is modeled on ChannelCombobox and resolves the DropdownMenu input incompatibilities: - Popover exposes onOpenAutoFocus so the search input receives focus immediately on open (via callback ref + preventDefault on the popover focus event) - No Radix menu typeahead to fight — all key events on the input are owned by the combobox handler - ArrowDown/ArrowUp cycle a highlightedIndex through the filtered list; Enter selects the highlighted item; Escape closes - Mouse hover syncs highlightedIndex so pointer and keyboard stay in sync - aria-label='Search models' on the input for accessibility - 'No models match' empty state when the filter returns nothing - Query and highlightedIndex reset on close Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/PersonaModelCombobox.tsx | 194 ++++++++++++++++++ .../features/agents/ui/PersonaModelField.tsx | 4 +- 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/agents/ui/PersonaModelCombobox.tsx diff --git a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx new file mode 100644 index 00000000000..2aa885e9a1a --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx @@ -0,0 +1,194 @@ +import * as React from "react"; +import { Check, ChevronDown, Search } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + type PersonaDropdownOption, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "./personaDialogPickers"; + +type PersonaModelComboboxProps = { + disabled?: boolean; + id: string; + onValueChange: (value: string) => void; + options: readonly PersonaDropdownOption[]; + placeholder: string; + value: string; +}; + +export function PersonaModelCombobox({ + disabled, + id, + onValueChange, + options, + placeholder, + value, +}: PersonaModelComboboxProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + + const selectedOption = options.find((option) => option.value === value); + + const filteredOptions = React.useMemo(() => { + if (query.trim() === "") return options; + const lower = query.toLowerCase(); + return options.filter((option) => + option.label.toLowerCase().includes(lower), + ); + }, [options, query]); + + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) { + setQuery(""); + setHighlightedIndex(0); + } + } + + function selectOption(optionValue: string) { + onValueChange(optionValue); + handleOpenChange(false); + } + + function handleKeyDown(event: React.KeyboardEvent) { + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + if (filteredOptions.length > 0) { + setHighlightedIndex((i) => (i + 1) % filteredOptions.length); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + if (filteredOptions.length > 0) { + setHighlightedIndex( + (i) => (i - 1 + filteredOptions.length) % filteredOptions.length, + ); + } + break; + } + case "Enter": { + event.preventDefault(); + const target = filteredOptions[highlightedIndex]; + if (target && !target.disabled) selectOption(target.value); + break; + } + case "Escape": { + event.preventDefault(); + handleOpenChange(false); + break; + } + } + } + + // Reset highlight to 0 whenever the filtered list changes so the + // highlighted row stays within bounds. + React.useEffect(() => { + setHighlightedIndex(0); + }, [filteredOptions]); + + return ( +
+ + + + + event.preventDefault()} + sideOffset={5} + style={{ + minWidth: "var(--radix-popover-trigger-width)", + width: "var(--radix-popover-trigger-width)", + }} + > +
+ + setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search models…" + // Popover supports onOpenAutoFocus; we preventDefault above so + // Radix doesn't move focus to the first focusable. But we still + // want the input focused immediately, so use the callback ref. + ref={(el) => el?.focus()} + spellCheck={false} + value={query} + /> +
+
event.stopPropagation()} + onWheelCapture={(event) => event.stopPropagation()} + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( + + )) + ) : ( +

+ No models match +

+ )} +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaModelField.tsx b/desktop/src/features/agents/ui/PersonaModelField.tsx index e0b4b92790b..5e47b9c7181 100644 --- a/desktop/src/features/agents/ui/PersonaModelField.tsx +++ b/desktop/src/features/agents/ui/PersonaModelField.tsx @@ -3,7 +3,7 @@ import { motion } from "motion/react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; -import { PersonaDropdownField } from "./PersonaDropdownField"; +import { PersonaModelCombobox } from "./PersonaModelCombobox"; import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; import { type PersonaDropdownOption, @@ -56,7 +56,7 @@ export function PersonaModelField({ Optional ) : null} - Date: Mon, 6 Jul 2026 13:40:10 -0400 Subject: [PATCH 2/5] fix(desktop): skip disabled options in model combobox keyboard nav ArrowDown/ArrowUp now walk past disabled options (e.g. 'Loading models...') so the highlight always lands on a selectable row. Mouse hover also no longer updates highlightedIndex for disabled rows. The initial highlight after a filter change starts at the first enabled option rather than index 0. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/PersonaModelCombobox.tsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx index 2aa885e9a1a..1df5235231b 100644 --- a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx +++ b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx @@ -53,21 +53,31 @@ export function PersonaModelCombobox({ handleOpenChange(false); } + // Walk the filtered list from `from` in `direction` (+1 or -1), wrapping + // around once, and return the first non-disabled index. Returns `from` if + // every option is disabled so the highlight doesn't vanish unexpectedly. + function nextEnabledIndex(from: number, direction: 1 | -1): number { + const len = filteredOptions.length; + for (let step = 1; step <= len; step++) { + const candidate = (from + direction * step + len * step) % len; + if (!filteredOptions[candidate]?.disabled) return candidate; + } + return from; + } + function handleKeyDown(event: React.KeyboardEvent) { switch (event.key) { case "ArrowDown": { event.preventDefault(); if (filteredOptions.length > 0) { - setHighlightedIndex((i) => (i + 1) % filteredOptions.length); + setHighlightedIndex((i) => nextEnabledIndex(i, 1)); } break; } case "ArrowUp": { event.preventDefault(); if (filteredOptions.length > 0) { - setHighlightedIndex( - (i) => (i - 1 + filteredOptions.length) % filteredOptions.length, - ); + setHighlightedIndex((i) => nextEnabledIndex(i, -1)); } break; } @@ -85,9 +95,21 @@ export function PersonaModelCombobox({ } } - // Reset highlight to 0 whenever the filtered list changes so the - // highlighted row stays within bounds. + // Reset highlight whenever the filtered list changes so the highlighted + // row stays within bounds and lands on the first enabled option. React.useEffect(() => { + if (filteredOptions.length === 0) { + setHighlightedIndex(0); + return; + } + // Walk forward from -1 to land on the first enabled index. + const len = filteredOptions.length; + for (let i = 0; i < len; i++) { + if (!filteredOptions[i]?.disabled) { + setHighlightedIndex(i); + return; + } + } setHighlightedIndex(0); }, [filteredOptions]); @@ -155,6 +177,7 @@ export function PersonaModelCombobox({ {filteredOptions.length > 0 ? ( filteredOptions.map((option, index) => (