feat(desktop): add typeahead search to persona model dropdown (#1542)

Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-06 15:01:09 -04:00
committed by GitHub
parent 346efe08b3
commit 3b43743e1f
5 changed files with 385 additions and 18 deletions

View File

@@ -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"],

View File

@@ -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 (
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Popover modal={false} onOpenChange={handleOpenChange} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
className={cn(
"flex h-11 w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm leading-6",
PERSONA_FIELD_CONTROL_CLASS,
disabled && "cursor-default opacity-60",
)}
disabled={disabled}
id={id}
role="combobox"
type="button"
>
<span
className={cn(
"min-w-0 flex-1 truncate",
!selectedOption && "text-muted-foreground/55",
)}
>
{selectedOption?.label ?? placeholder}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground/60" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="overflow-hidden p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
sideOffset={5}
style={{
minWidth: "var(--radix-popover-trigger-width)",
width: "var(--radix-popover-trigger-width)",
}}
>
<div className="group/search flex cursor-text items-center gap-2 border-b border-border/50 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-focus-within/search:text-foreground" />
<input
aria-label="Search models"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="block min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 text-muted-foreground/55 shadow-none outline-none placeholder:text-muted-foreground/55 focus:text-foreground focus:placeholder:text-muted-foreground"
onChange={(event) => 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}
/>
</div>
<div
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto overscroll-contain p-1"
onTouchMoveCapture={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => (
<button
aria-disabled={option.disabled}
className={cn(
"relative flex min-h-9 w-full select-none items-center rounded-lg py-2 pl-8 pr-4 text-left text-sm outline-none transition-colors",
option.disabled
? "pointer-events-none opacity-50"
: "cursor-default hover:bg-muted/50 hover:text-foreground",
index === highlightedIndex &&
!option.disabled &&
"bg-muted/50 text-foreground",
option.value === value && "font-medium",
)}
disabled={option.disabled}
key={option.value}
onClick={() => selectOption(option.value)}
onMouseEnter={() => {
if (!option.disabled) setHighlightedIndex(index);
}}
type="button"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check
className={cn(
"h-3.5 w-3.5",
option.value === value ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="truncate">{option.label}</span>
</button>
))
) : (
<p className="px-3 py-6 text-center text-sm text-muted-foreground/55">
No models match
</p>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -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({
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
) : null}
</label>
<PersonaDropdownField
<PersonaModelCombobox
disabled={disabled}
id="persona-model"
onValueChange={onModelValueChange}

View File

@@ -58,22 +58,17 @@ async function invokeTauri<T>(
);
}
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 <input> and plain <button> 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.

View File

@@ -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` });
});
});