feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
@@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response {
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function mockFetchRoutes(routes: Record<string, unknown>): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const body = routes[String(input)];
|
||||
return body === undefined
|
||||
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
|
||||
: jsonResponse(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function baseSettingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
@@ -208,6 +220,7 @@ describe("App layout", () => {
|
||||
runStatusHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
@@ -243,6 +256,129 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens Skills from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/github": {
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
requirements: {
|
||||
bins: ["gh"],
|
||||
env: [],
|
||||
missing_bins: ["gh"],
|
||||
missing_env: [],
|
||||
},
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
|
||||
|
||||
fireEvent.click(skillsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(document.title).toBe("Skills · nanobot");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
|
||||
expect(await screen.findByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Raw SKILL.md"));
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fully collapses the native host sidebar and previews it on hover", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Desktop chat",
|
||||
},
|
||||
];
|
||||
vi.mocked(fetchBootstrap).mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
runtime_surface: "native",
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const flowSidebar = screen.getByTestId("host-sidebar-flow");
|
||||
const toggle = screen.getByTestId("host-sidebar-toggle");
|
||||
expect(flowSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(toggle);
|
||||
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
|
||||
expect(flowSidebar).toHaveStyle({ width: "0px" });
|
||||
expect(previewSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
within(previewSidebar).getByRole("navigation", {
|
||||
name: "Sidebar navigation",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(flowSidebar).toHaveStyle({ width: "272px" });
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Sidebar navigation" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the next session when deleting the active chat", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
@@ -907,7 +1043,6 @@ describe("App layout", () => {
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(document.title).toBe("Settings · nanobot");
|
||||
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
|
||||
@@ -1036,15 +1171,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("restores the settings section from the URL hash after a page reload", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
window.history.replaceState(null, "", "/#/settings?section=models");
|
||||
|
||||
render(<App />);
|
||||
@@ -1055,15 +1182,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("updates the URL hash when switching settings sections", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -1081,22 +1200,11 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
if (href === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
|
||||
}
|
||||
if (href === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user