mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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"],
|
||||
|
||||
220
desktop/src/features/agents/ui/PersonaModelCombobox.tsx
Normal file
220
desktop/src/features/agents/ui/PersonaModelCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
151
desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts
Normal file
151
desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts
Normal 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` });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user