@@ -108,8 +108,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
{automationContent}
-
-
+
+
);
}
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index 1f542a2ef..2492a8fb8 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -50,6 +50,10 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
+import {
+ floatingItemClassName,
+ floatingSurfaceVisualClassName,
+} from "@/components/ui/floating-surface";
import {
Tooltip,
TooltipContent,
@@ -2688,10 +2692,9 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
- "absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
+ floatingSurfaceVisualClassName,
+ "absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
- "border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
- "dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
@@ -2728,7 +2731,8 @@ function CliAppMentionPalette({
onChoose(candidate);
}}
className={cn(
- "flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors",
+ floatingItemClassName,
+ "flex min-h-10 w-full items-center gap-2.5 px-2.5 py-1.5 text-left transition-colors",
selected
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
@@ -2836,10 +2840,9 @@ function SlashCommandPalette({
aria-label={t("thread.composer.slash.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
- "absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
+ floatingSurfaceVisualClassName,
+ "absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
- "border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
- "dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
@@ -2868,7 +2871,8 @@ function SlashCommandPalette({
onChoose(command);
}}
className={cn(
- "flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
+ floatingItemClassName,
+ "flex min-h-[44px] w-full items-center gap-3 px-3 py-2 text-left transition-colors",
selected
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
diff --git a/webui/src/components/thread/WorkspaceControls.tsx b/webui/src/components/thread/WorkspaceControls.tsx
index 44ba60675..28aa72698 100644
--- a/webui/src/components/thread/WorkspaceControls.tsx
+++ b/webui/src/components/thread/WorkspaceControls.tsx
@@ -6,10 +6,20 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import {
+ floatingItemClassName,
+ floatingItemFocusClassName,
+} from "@/components/ui/floating-surface";
import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import type {
WorkspaceAccessMode,
WorkspaceScopePayload,
@@ -134,8 +144,8 @@ export function WorkspaceProjectPicker({
return (
-
-
+
+
-
-
+
- applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
- className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
+
+
- {
- if (event.key !== "Escape") event.stopPropagation();
- }}
- >
+
-
-
+
+
);
}
@@ -284,46 +294,51 @@ export function WorkspaceAccessMenu({
- }
- label={t("thread.composer.workspace.default")}
- selected={mode === "restricted"}
- onSelect={() => setMode("restricted")}
- />
- }
- label={t("thread.composer.workspace.full")}
- selected={mode === "full"}
- disabled={!canUseFullAccess}
- warning
- onSelect={() => setMode("full")}
- />
+ {
+ if (value === "restricted" || value === "full") setMode(value);
+ }}
+ >
+ }
+ label={t("thread.composer.workspace.default")}
+ />
+ }
+ label={t("thread.composer.workspace.full")}
+ disabled={!canUseFullAccess}
+ warning
+ />
+
);
}
function AccessMenuItem({
+ value,
icon,
label,
- selected,
disabled,
warning,
- onSelect,
}: {
+ value: WorkspaceAccessMode;
icon: ReactNode;
label: string;
- selected: boolean;
disabled?: boolean;
warning?: boolean;
- onSelect: () => void;
}) {
return (
-
@@ -331,7 +346,6 @@ function AccessMenuItem({
{icon}
{label}
- {selected ? : null}
-
+
);
}
diff --git a/webui/src/components/ui/combobox.tsx b/webui/src/components/ui/combobox.tsx
new file mode 100644
index 000000000..8f78aeebf
--- /dev/null
+++ b/webui/src/components/ui/combobox.tsx
@@ -0,0 +1,137 @@
+import * as React from "react";
+
+import {
+ floatingItemClassName,
+ floatingItemFocusClassName,
+} from "@/components/ui/floating-surface";
+import { cn } from "@/lib/utils";
+
+interface ComboboxNavigationOptions {
+ open: boolean;
+ values: readonly string[];
+ selectedValue?: string;
+ onSelect: (value: string) => void;
+ onClose: () => void;
+}
+
+export function useComboboxNavigation({
+ open,
+ values,
+ selectedValue,
+ onSelect,
+ onClose,
+}: ComboboxNavigationOptions) {
+ const listboxId = React.useId();
+ const [activeValue, setActiveValue] = React.useState
(null);
+
+ React.useEffect(() => {
+ if (!open) return;
+ setActiveValue((current) => {
+ if (current && values.includes(current)) return current;
+ if (selectedValue && values.includes(selectedValue)) return selectedValue;
+ return values[0] ?? null;
+ });
+ }, [open, selectedValue, values]);
+
+ const activeIndex = activeValue ? values.indexOf(activeValue) : -1;
+ const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
+
+ React.useEffect(() => {
+ if (!activeOptionId) return;
+ const option = document.getElementById(activeOptionId);
+ option?.scrollIntoView?.({ block: "nearest" });
+ }, [activeOptionId]);
+
+ const move = (offset: number) => {
+ if (!values.length) return;
+ const nextIndex = activeIndex < 0
+ ? offset > 0 ? 0 : values.length - 1
+ : (activeIndex + offset + values.length) % values.length;
+ setActiveValue(values[nextIndex]);
+ };
+
+ const onInputKeyDown = (event: React.KeyboardEvent) => {
+ if (event.nativeEvent.isComposing) return;
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault();
+ move(1);
+ break;
+ case "ArrowUp":
+ event.preventDefault();
+ move(-1);
+ break;
+ case "Home":
+ if (values.length) {
+ event.preventDefault();
+ setActiveValue(values[0]);
+ }
+ break;
+ case "End":
+ if (values.length) {
+ event.preventDefault();
+ setActiveValue(values[values.length - 1]);
+ }
+ break;
+ case "Enter":
+ if (activeValue) {
+ event.preventDefault();
+ onSelect(activeValue);
+ }
+ break;
+ case "Escape":
+ event.preventDefault();
+ onClose();
+ break;
+ }
+ };
+
+ const inputProps = {
+ role: "combobox" as const,
+ "aria-autocomplete": "list" as const,
+ "aria-controls": listboxId,
+ "aria-expanded": open,
+ "aria-activedescendant": activeOptionId,
+ onKeyDown: onInputKeyDown,
+ };
+
+ const listProps = {
+ id: listboxId,
+ role: "listbox" as const,
+ };
+
+ const getOptionProps = (value: string) => {
+ const index = values.indexOf(value);
+ return {
+ id: `${listboxId}-option-${index}`,
+ role: "option" as const,
+ "aria-selected": value === selectedValue,
+ "data-highlighted": value === activeValue ? "" : undefined,
+ tabIndex: -1,
+ onPointerMove: () => setActiveValue(value),
+ onClick: () => onSelect(value),
+ };
+ };
+
+ return { inputProps, listProps, getOptionProps };
+}
+
+const ComboboxOption = React.forwardRef<
+ HTMLButtonElement,
+ React.ButtonHTMLAttributes
+>(({ className, type = "button", ...props }, ref) => (
+
+));
+ComboboxOption.displayName = "ComboboxOption";
+
+export { ComboboxOption };
diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx
index 7bc738a15..5e35e5830 100644
--- a/webui/src/components/ui/dropdown-menu.tsx
+++ b/webui/src/components/ui/dropdown-menu.tsx
@@ -2,6 +2,12 @@ import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
+import {
+ floatingItemClassName,
+ floatingItemFocusClassName,
+ floatingSurfaceClassName,
+ floatingSurfaceMotionClassName,
+} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -11,11 +17,8 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
-const menuContentClassName =
- "z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
-
const menuItemClassName =
- "relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
+ `${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef,
@@ -46,7 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
,
React.ComponentPropsWithoutRef & {
inset?: boolean;
+ tone?: "default" | "destructive";
}
->(({ className, inset, ...props }, ref) => (
+>(({ className, inset, tone = "default", ...props }, ref) => (
,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ indicator?: "dot" | "check";
+ indicatorPosition?: "start" | "end";
+ }
+>(({ className, children, indicator = "dot", indicatorPosition = "start", ...props }, ref) => (
-
+
-
+ {indicator === "check" ? (
+
+ ) : (
+
+ )}
{children}
diff --git a/webui/src/components/ui/floating-surface.ts b/webui/src/components/ui/floating-surface.ts
new file mode 100644
index 000000000..ea416c655
--- /dev/null
+++ b/webui/src/components/ui/floating-surface.ts
@@ -0,0 +1,14 @@
+export const floatingSurfaceVisualClassName =
+ "rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
+
+export const floatingSurfaceClassName =
+ `${floatingSurfaceVisualClassName} z-50 max-h-[min(var(--radix-popper-available-height),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
+
+export const floatingSurfaceMotionClassName =
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
+
+export const floatingItemClassName =
+ "relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
+
+export const floatingItemFocusClassName =
+ "focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
diff --git a/webui/src/components/ui/popover.tsx b/webui/src/components/ui/popover.tsx
new file mode 100644
index 000000000..73ff2aec6
--- /dev/null
+++ b/webui/src/components/ui/popover.tsx
@@ -0,0 +1,41 @@
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+
+import {
+ floatingSurfaceClassName,
+ floatingSurfaceMotionClassName,
+} from "@/components/ui/floating-surface";
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+interface PopoverContentProps
+ extends React.ComponentPropsWithoutRef {
+ portalContainer?: HTMLElement | null;
+}
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ PopoverContentProps
+>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+};
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index aaeb2cb8d..5284ab741 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -1715,6 +1716,7 @@ describe("App layout", () => {
});
it("opens the settings view from the sidebar footer", async () => {
+ const user = userEvent.setup();
mockSessions = [
{
key: "websocket:chat-a",
@@ -1729,6 +1731,18 @@ describe("App layout", () => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
+ if (href === "/api/settings/api-service") {
+ return jsonResponse({
+ installed: false,
+ running: false,
+ managed: false,
+ host: "127.0.0.1",
+ port: 8900,
+ timeout: 120,
+ endpoint: "http://127.0.0.1:8900/v1",
+ command: "nanobot serve",
+ });
+ }
if (href === "/api/settings/provider-models?provider=openai") {
return jsonResponse({
provider: "openai",
@@ -1996,8 +2010,8 @@ describe("App layout", () => {
.getAllByRole("button", { name: /OpenAI/ })
.some((button) => button.getAttribute("aria-haspopup") === "menu"),
).toBe(true);
- fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
- fireEvent.click(await screen.findByText("openai/gpt-4o-mini"));
+ await user.click(screen.getByRole("button", { name: "Select model" }));
+ await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
@@ -2006,14 +2020,13 @@ describe("App layout", () => {
);
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
- fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
- const openModelPicker = () => {
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /Auto/ }));
+ const openModelPicker = async () => {
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
- fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
+ await user.click(modelButtons[modelButtons.length - 1]);
};
- openModelPicker();
- await screen.findByText("openai/gpt-4o-mini");
- fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
+ await openModelPicker();
+ await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
expect(screen.getByText("Model providers")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
@@ -2078,9 +2091,9 @@ describe("App layout", () => {
target: { value: "unsaved-brave-key" },
});
fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ }));
- fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" }));
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Tavily" }));
fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ }));
- fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Brave Search" }));
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
@@ -2095,12 +2108,13 @@ describe("App layout", () => {
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
- expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
- fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
+ fireEvent.click(screen.getByRole("button", { name: "UTC" }));
+ const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
+ expect(timezoneSearch).toBeInTheDocument();
+ fireEvent.change(timezoneSearch, {
target: { value: "Shanghai" },
});
- fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
+ await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ }));
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
diff --git a/webui/src/tests/combobox.test.tsx b/webui/src/tests/combobox.test.tsx
new file mode 100644
index 000000000..40a0266dd
--- /dev/null
+++ b/webui/src/tests/combobox.test.tsx
@@ -0,0 +1,70 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { describe, expect, it } from "vitest";
+
+import {
+ ComboboxOption,
+ useComboboxNavigation,
+} from "@/components/ui/combobox";
+
+const OPTIONS = ["Alpha", "Beta", "Gamma"];
+
+function ComboboxHarness() {
+ const [open, setOpen] = useState(true);
+ const [selected, setSelected] = useState("Beta");
+ const navigation = useComboboxNavigation({
+ open,
+ values: OPTIONS,
+ selectedValue: selected,
+ onSelect: setSelected,
+ onClose: () => setOpen(false),
+ });
+
+ return (
+ <>
+
+ {open ? (
+
+ {OPTIONS.map((option) => (
+
+ {option}
+
+ ))}
+
+ ) : null}
+
+ >
+ );
+}
+
+describe("combobox navigation", () => {
+ it("exposes listbox semantics and selects the active option from the keyboard", () => {
+ render();
+
+ const input = screen.getByRole("combobox", { name: "Options" });
+ expect(input).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ fireEvent.keyDown(input, { key: "ArrowDown" });
+ expect(input).toHaveAttribute(
+ "aria-activedescendant",
+ screen.getByRole("option", { name: "Gamma" }).id,
+ );
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma");
+ });
+
+ it("closes the listbox on Escape", () => {
+ render();
+
+ fireEvent.keyDown(screen.getByRole("combobox", { name: "Options" }), {
+ key: "Escape",
+ });
+
+ expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
+ });
+});
diff --git a/webui/src/tests/session-info-popover.test.tsx b/webui/src/tests/session-info-popover.test.tsx
index 7bb059545..f2863ce74 100644
--- a/webui/src/tests/session-info-popover.test.tsx
+++ b/webui/src/tests/session-info-popover.test.tsx
@@ -56,6 +56,8 @@ describe("SessionInfoPopover", () => {
await user.click(screen.getByRole("button", { name: "Session details" }));
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations",
diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx
index 2b0876b10..ebc53dc9b 100644
--- a/webui/src/tests/settings-view.test.tsx
+++ b/webui/src/tests/settings-view.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
@@ -372,6 +373,10 @@ async function togglePresetEditor(name = "primary") {
fireEvent.click(within(row).getAllByRole("button")[0]);
}
+async function openPopover(trigger: HTMLElement) {
+ await userEvent.setup().click(trigger);
+}
+
async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }),
@@ -2460,8 +2465,8 @@ describe("SettingsView Apps catalog", () => {
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
target: { value: "Writer" },
});
- fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
- const modelSearch = await screen.findByRole("textbox", {
+ await openPopover(screen.getByRole("button", { name: "Select model" }));
+ const modelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(modelSearch, {
@@ -3206,7 +3211,7 @@ describe("SettingsView Apps catalog", () => {
target: { value: "http://127.0.0.1:7890" },
});
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "enable_thinking" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => {
@@ -3272,27 +3277,27 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
- fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" }));
+ await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
+ fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
);
- fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
- const modelInput = await screen.findByRole("textbox", { name: "Search or type model ID" });
+ await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
+ const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
- fireEvent.click(await screen.findByRole("menuitem", { name: "Use “imagen-5-preview”" }));
+ fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
- fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-5-preview" }));
- const customProviderInput = await screen.findByRole("textbox", {
+ await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
+ const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID",
});
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
@@ -3572,9 +3577,9 @@ describe("SettingsView Apps catalog", () => {
if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker);
- expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
- expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
- expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
+ expect(await screen.findByRole("menuitemradio", { name: /DeepSeek/ })).toBeInTheDocument();
+ expect(screen.queryByRole("menuitemradio", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("menuitemradio", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
});
it("does not fetch model lists for unsigned OAuth providers", async () => {
@@ -3638,7 +3643,7 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "models" });
await togglePresetEditor();
- fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
+ await openPopover(await screen.findByRole("button", { name: /Select model/i }));
expect(
await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument();
@@ -3698,7 +3703,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
- fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
+ await openPopover(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5");
@@ -3776,7 +3781,7 @@ describe("SettingsView Apps catalog", () => {
const modelButtons = await screen.findAllByRole("button", {
name: /openai-codex\/gpt-5\.5/i,
});
- fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
+ await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
@@ -3900,7 +3905,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
- fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
+ await openPopover(modelButtons[modelButtons.length - 1]);
await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
@@ -4023,7 +4028,7 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "browser" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
- fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "Keenable" }));
const saveButton = screen
.getAllByRole("button", { name: "Save" })
.find((button) => !(button as HTMLButtonElement).disabled);
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index af99b1a20..297fa3470 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -967,7 +968,7 @@ describe("ThreadComposer", () => {
);
fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ }));
- fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: /Full Access/ }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1007,6 +1008,7 @@ describe("ThreadComposer", () => {
});
it("keeps project selection as a compact composer dropdown", async () => {
+ const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn();
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
@@ -1030,10 +1032,10 @@ describe("ThreadComposer", () => {
/>,
);
- fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
+ await user.click(screen.getByRole("button", { name: "Choose project" }));
- expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
const input = screen.getByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
@@ -1054,7 +1056,7 @@ describe("ThreadComposer", () => {
restrict_to_workspace: false,
}));
- fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
+ await user.click(screen.getByRole("button", { name: "Choose project" }));
const reopenedInput = await screen.findByLabelText("Paste path");
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
@@ -1102,7 +1104,7 @@ describe("ThreadComposer", () => {
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
- expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /Default workspace/ })).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/native-project",
project_name: "native-project",
@@ -1112,6 +1114,7 @@ describe("ThreadComposer", () => {
});
it("uses the web path menu when no native host picker is available", async () => {
+ const user = userEvent.setup();
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
@@ -1131,9 +1134,9 @@ describe("ThreadComposer", () => {
/>,
);
- fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
+ await user.click(screen.getByRole("button", { name: "Choose project" }));
- expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
});