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 (
+
+
+
+
+
+ {selectedOption?.label ?? placeholder}
+
+
+
+
+ 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) => (
+
selectOption(option.value)}
+ onMouseEnter={() => {
+ if (!option.disabled) setHighlightedIndex(index);
+ }}
+ type="button"
+ >
+
+
+
+ {option.label}
+
+ ))
+ ) : (
+
+ 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 options — not a role="menu".
await model.click();
- const menu = page
- .getByRole("menu")
- .filter({
- has: page.getByRole("menuitemradio", {
- name: "Custom model...",
- exact: true,
- }),
- })
- .last();
- await expect(menu).toBeVisible();
- return menu;
+ const searchInput = page.getByPlaceholder("Search models…");
+ await expect(searchInput).toBeVisible({ timeout: 5_000 });
+ // Return the popover content container so callers can scope option clicks.
+ return page.locator("[data-radix-popper-content-wrapper]").last();
}
async function selectDropdownOption(
@@ -339,10 +334,10 @@ test("persona model options follow the selected LLM provider", async ({
await expect(page.getByTestId("env-vars-editor")).toHaveCount(0);
await expect(model).toBeVisible();
// OpenAI requires an explicit model, so "Default model" is filtered out.
- // The menu offers only "Custom model..." — verify it is present and selectable.
- const openAiModelMenu = await openModelMenu(page, model);
- await openAiModelMenu
- .getByRole("menuitemradio", { name: "Custom model...", exact: true })
+ // The combobox offers only "Custom model..." — verify it is present and selectable.
+ const openAiModelPopover = await openModelCombobox(page, model);
+ await openAiModelPopover
+ .getByRole("button", { name: "Custom model...", exact: true })
.click();
// Switch to Anthropic — API-key field label changes and value clears.
diff --git a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts
new file mode 100644
index 00000000000..d57c9edb06c
--- /dev/null
+++ b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts
@@ -0,0 +1,151 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
+
+const SHOTS = "test-results/persona-model-combobox";
+
+async function waitForInvokeBridge(page: import("@playwright/test").Page) {
+ await page.waitForFunction(
+ () => {
+ const w = window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
+ __TAURI_INTERNALS__?: { invoke?: unknown };
+ };
+ return (
+ typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
+ typeof w.__TAURI_INTERNALS__?.invoke === "function"
+ );
+ },
+ null,
+ { timeout: 8_000 },
+ );
+}
+
+/**
+ * Open the Agents view, click "New agent", and open the persona create dialog.
+ * Returns the dialog locator.
+ */
+async function openNewPersonaDialog(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await waitForInvokeBridge(page);
+
+ await page.getByTestId("open-agents-view").click();
+ await expect(page.getByTestId("agents-library-personas")).toBeVisible({
+ timeout: 8_000,
+ });
+
+ await page.getByTestId("new-agent-card").click();
+ await page.getByRole("menuitem", { name: "New agent" }).click();
+
+ const dialog = page.getByTestId("persona-dialog");
+ await expect(dialog).toBeVisible({ timeout: 8_000 });
+ return dialog;
+}
+
+/**
+ * The mock bridge selects Goose as the default runtime (available + preferred).
+ * Wait for model discovery to populate the combobox options so the list is
+ * non-trivial before opening the popover.
+ */
+async function waitForModelCombobox(
+ dialog: import("@playwright/test").Locator,
+) {
+ // The model field only appears after a runtime is selected. Goose is the
+ // mock default; the field should appear without manual interaction.
+ const trigger = dialog.getByRole("combobox", { name: /model/i });
+ await expect(trigger).toBeVisible({ timeout: 8_000 });
+ return trigger;
+}
+
+test.describe("persona model combobox screenshots", () => {
+ test.use({ viewport: { width: 1280, height: 900 } });
+
+ test.beforeEach(async ({ page }) => {
+ page.on("pageerror", (err) => {
+ console.error(
+ "PAGE ERROR:",
+ err.message,
+ err.stack?.split("\n").slice(0, 5).join("\n"),
+ );
+ });
+ page.on("console", (msg) => {
+ if (msg.type() === "error") {
+ console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
+ }
+ });
+ await installMockBridge(page);
+ });
+
+ test("01 — closed trigger (model not yet selected)", async ({ page }) => {
+ const dialog = await openNewPersonaDialog(page);
+ await waitForModelCombobox(dialog);
+ await waitForAnimations(page);
+
+ await dialog.screenshot({ path: `${SHOTS}/01-closed-trigger.png` });
+ });
+
+ test("02 — open popover with full model list", async ({ page }) => {
+ const dialog = await openNewPersonaDialog(page);
+ const trigger = await waitForModelCombobox(dialog);
+
+ await trigger.click();
+
+ // Wait for the search input to appear (popover is open).
+ await expect(page.getByPlaceholder("Search models…")).toBeVisible({
+ timeout: 5_000,
+ });
+
+ // Wait for model discovery to populate at least one non-loading row.
+ await expect(
+ page.getByRole("button", { name: /claude|gpt|default/i }).first(),
+ ).toBeVisible({ timeout: 8_000 });
+
+ await waitForAnimations(page);
+ await dialog.screenshot({ path: `${SHOTS}/02-open-full-list.png` });
+ });
+
+ test("03 — filtered results (query: gpt)", async ({ page }) => {
+ const dialog = await openNewPersonaDialog(page);
+ const trigger = await waitForModelCombobox(dialog);
+
+ await trigger.click();
+
+ const searchInput = page.getByPlaceholder("Search models…");
+ await expect(searchInput).toBeVisible({ timeout: 5_000 });
+ await expect(
+ page.getByRole("button", { name: /claude|gpt|default/i }).first(),
+ ).toBeVisible({ timeout: 8_000 });
+
+ await searchInput.fill("gpt");
+
+ // At least one GPT option should be visible; Claude options gone.
+ await expect(
+ page.getByRole("button", { name: /gpt/i }).first(),
+ ).toBeVisible({ timeout: 3_000 });
+
+ await waitForAnimations(page);
+ await dialog.screenshot({ path: `${SHOTS}/03-filtered-gpt.png` });
+ });
+
+ test("04 — empty state (no models match)", async ({ page }) => {
+ const dialog = await openNewPersonaDialog(page);
+ const trigger = await waitForModelCombobox(dialog);
+
+ await trigger.click();
+
+ const searchInput = page.getByPlaceholder("Search models…");
+ await expect(searchInput).toBeVisible({ timeout: 5_000 });
+ await expect(
+ page.getByRole("button", { name: /claude|gpt|default/i }).first(),
+ ).toBeVisible({ timeout: 8_000 });
+
+ await searchInput.fill("zzznomatch");
+
+ await expect(page.getByText("No models match")).toBeVisible({
+ timeout: 3_000,
+ });
+ await waitForAnimations(page);
+ await dialog.screenshot({ path: `${SHOTS}/04-empty-state.png` });
+ });
+});