diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac6..861a04f9885 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", "**/send-channel-binding.spec.ts", + "**/persona-model-combobox-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx new file mode 100644 index 00000000000..9ac98a1bfef --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx @@ -0,0 +1,220 @@ +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); + } + + // 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) => nextEnabledIndex(i, 1)); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + if (filteredOptions.length > 0) { + setHighlightedIndex((i) => nextEnabledIndex(i, -1)); + } + 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 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]); + + 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} - ( ); } -async function openModelMenu( +async function openModelCombobox( page: import("@playwright/test").Page, model: import("@playwright/test").Locator, ) { + // PersonaModelCombobox renders a role="combobox" trigger + a Radix Popover + // with a search and plain