fix(webui): improve UX recovery and empty states (#5315)
This commit is contained in:
@@ -315,11 +315,68 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
const password = screen.getByLabelText("Password");
|
||||
expect(password).toHaveAttribute(
|
||||
"autocomplete",
|
||||
"current-password",
|
||||
);
|
||||
expect(password).not.toHaveAttribute("placeholder");
|
||||
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles password visibility without changing the password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
await user.type(password, "correct horse battery staple");
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show password" }));
|
||||
|
||||
expect(password).toHaveAttribute("type", "text");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
const hidePassword = screen.getByRole("button", { name: "Hide password" });
|
||||
expect(hidePassword).toHaveFocus();
|
||||
|
||||
await user.click(hidePassword);
|
||||
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("explains and focuses an empty auth password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValue(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
const connect = screen.getByRole("button", { name: "Connect" });
|
||||
expect(connect).toBeEnabled();
|
||||
fireEvent.click(connect);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Enter your password.",
|
||||
);
|
||||
expect(password).toHaveAttribute("aria-invalid", "true");
|
||||
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
|
||||
expect(password).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the auth form when bootstrap does not issue an API token", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new BootstrapAuthRequiredError(
|
||||
@@ -329,8 +386,11 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -341,11 +401,16 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByPlaceholderText("Password");
|
||||
const password = await screen.findByLabelText("Password");
|
||||
fireEvent.change(password, { target: { value: "wrong-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
|
||||
const retryPassword = await screen.findByLabelText("Password");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Incorrect password. Try again.",
|
||||
);
|
||||
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
|
||||
expect(retryPassword).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -365,6 +430,21 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses one main landmark and a page heading in desktop settings", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -652,6 +732,57 @@ describe("App layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the first message when the gateway rejects a project", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
createChatSpy.mockRejectedValueOnce(
|
||||
new Error("workspace_scope_rejected:project_path must be an existing directory"),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "restricted",
|
||||
default_scope: {
|
||||
project_path: "C:\\workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
|
||||
fireEvent.change(await screen.findByLabelText("Paste path"), {
|
||||
target: { value: "C:\\missing-project" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
|
||||
const message = screen.getByLabelText("Message input");
|
||||
fireEvent.change(message, { target: { value: "keep this first message" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(message).toHaveValue("keep this first message");
|
||||
const projectButton = screen.getByRole("button", { name: "Choose project" });
|
||||
await waitFor(() => expect(projectButton).toHaveFocus());
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
fireEvent.click(projectButton);
|
||||
const projectPath = await screen.findByLabelText("Paste path");
|
||||
expect(projectPath).toHaveValue("C:\\missing-project");
|
||||
expect(projectPath).toHaveAttribute("aria-invalid", "true");
|
||||
expect(projectPath).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
expect(window.location.hash).toBe("");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
|
||||
@@ -347,6 +347,7 @@ function renderSettingsView(
|
||||
| "runtime";
|
||||
initialSettings?: SettingsPayload;
|
||||
showSidebar?: boolean;
|
||||
onBackToChat?: () => void;
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
onNativeEngineRestart?: () => Promise<string>;
|
||||
} = {},
|
||||
@@ -359,7 +360,7 @@ function renderSettingsView(
|
||||
initialSettings={options.initialSettings}
|
||||
showSidebar={options.showSidebar}
|
||||
onToggleTheme={() => {}}
|
||||
onBackToChat={() => {}}
|
||||
onBackToChat={options.onBackToChat ?? (() => {})}
|
||||
onModelNameChange={() => {}}
|
||||
onSettingsChange={options.onSettingsChange}
|
||||
onNativeEngineRestart={options.onNativeEngineRestart}
|
||||
@@ -385,6 +386,9 @@ async function chooseProviderToConfigure(label: string) {
|
||||
}
|
||||
|
||||
describe("SettingsView Apps catalog", () => {
|
||||
const thirdPartyBrandNotice =
|
||||
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
@@ -428,7 +432,34 @@ describe("SettingsView Apps catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the third-party brand notice only with the brand logo preference", () => {
|
||||
renderSettingsView({
|
||||
initialSection: "appearance",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: true,
|
||||
});
|
||||
|
||||
const brandLogosTitle = screen.getByText("Brand logos");
|
||||
const brandLogosRow = brandLogosTitle.parentElement?.parentElement;
|
||||
|
||||
expect(brandLogosRow).not.toBeNull();
|
||||
expect(
|
||||
within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["apps", "channels"] as const)(
|
||||
"does not repeat the third-party brand notice in %s",
|
||||
(initialSection) => {
|
||||
renderSettingsView({ initialSection, initialSettings: settingsPayload() });
|
||||
|
||||
expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not show the Settings kicker on the standalone Automations surface", async () => {
|
||||
const onBackToChat = vi.fn();
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
@@ -440,11 +471,49 @@ describe("SettingsView Apps catalog", () => {
|
||||
initialSection: "automations",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: false,
|
||||
onBackToChat,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Automations" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("No automations yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText("Search task, message, linked chat, or schedule"),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open a chat" }));
|
||||
expect(onBackToChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers a way out of an empty automations filter", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||
if (url === "/api/webui/automations") {
|
||||
return jsonResponse({
|
||||
jobs: [{
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 9 * * *" },
|
||||
payload: { message: "Summarize the day" },
|
||||
state: {},
|
||||
}],
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
}));
|
||||
|
||||
renderSettingsView({
|
||||
initialSection: "automations",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Paused 0" }));
|
||||
expect(await screen.findByText("No automations match this view.")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
expect(await screen.findByRole("heading", { name: "Daily summary" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("coalesces focus refreshes while automations are already loading", async () => {
|
||||
@@ -1963,8 +2032,10 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByText("No tools match this view.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No apps available.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse integrations" }));
|
||||
expect(await screen.findByText("Add integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows token activity on the overview", async () => {
|
||||
|
||||
@@ -350,6 +350,30 @@ function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("locks an async send and keeps the draft when it is rejected", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
const onSend = vi.fn(() => new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}));
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "keep this pending draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled();
|
||||
await act(async () => resolveSend(false));
|
||||
|
||||
await waitFor(() => expect(input).toBeEnabled());
|
||||
expect(input).toHaveValue("keep this pending draft");
|
||||
});
|
||||
|
||||
it("dismisses the touch keyboard after a successful send", async () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
@@ -1113,6 +1137,36 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Windows", "D:\\Users\\test\\.nanobot\\workspace", "D:\\path\\to\\project"],
|
||||
["macOS", "/Users/test/.nanobot/workspace", "/Users/name/project"],
|
||||
["Linux", "/home/test/.nanobot/workspace", "/home/name/project"],
|
||||
])("uses a %s path example for the project picker", async (_, projectPath, placeholder) => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
project_path: projectPath,
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted" as const,
|
||||
restrict_to_workspace: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByLabelText("Paste path")).toHaveAttribute("placeholder", placeholder);
|
||||
});
|
||||
|
||||
it("slides project controls closed without offering a compact replacement", () => {
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
|
||||
Reference in New Issue
Block a user