refactor(webui): unify floating controls

This commit is contained in:
Xubin Ren
2026-08-04 18:05:34 +08:00
parent faff0ac2fa
commit 7819cef7bd
16 changed files with 777 additions and 257 deletions
+28 -14
View File
@@ -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();
});
+70
View File
@@ -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 (
<>
<input aria-label="Options" {...navigation.inputProps} />
{open ? (
<div {...navigation.listProps} aria-label="Available options">
{OPTIONS.map((option) => (
<ComboboxOption key={option} {...navigation.getOptionProps(option)}>
{option}
</ComboboxOption>
))}
</div>
) : null}
<output aria-label="Selection">{selected}</output>
</>
);
}
describe("combobox navigation", () => {
it("exposes listbox semantics and selects the active option from the keyboard", () => {
render(<ComboboxHarness />);
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(<ComboboxHarness />);
fireEvent.keyDown(screen.getByRole("combobox", { name: "Options" }), {
key: "Escape",
});
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
});
});
@@ -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",
+25 -20
View File
@@ -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);
+11 -8
View File
@@ -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();
});