feat(apps): unify CLI apps and MCP (#3991)
* refactor(cli): load bundled apps from catalog * feat(plugins): unify CLI and MCP settings * feat(plugins): add settings category filter * style(plugins): refine settings catalog * refactor(cli): load nanobot apps from repo catalog * feat(store): add capability store entry * feat(apps): rename capability store * fix(apps): verify clean app removal * fix(apps): keep main sidebar on apps view * feat(apps): add shared app manifest protocol * fix(apps): dismiss app status message * refactor(apps): move CLI adapter under apps * refactor(apps): drop legacy cli apps package
This commit is contained in:
@@ -13,6 +13,100 @@ const attachSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function baseSettingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
has_api_key: true,
|
||||
model_preset: "default",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
timezone: "UTC",
|
||||
bot_name: "nanobot",
|
||||
bot_icon: "nb",
|
||||
tool_hint_max_length: 40,
|
||||
},
|
||||
model_presets: [{
|
||||
name: "default",
|
||||
label: "Default",
|
||||
active: true,
|
||||
is_default: true,
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
providers: [],
|
||||
web_search: {
|
||||
provider: "duckduckgo",
|
||||
api_key_hint: null,
|
||||
base_url: null,
|
||||
max_results: 5,
|
||||
timeout: 30,
|
||||
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
||||
},
|
||||
web: {
|
||||
enable: true,
|
||||
proxy: null,
|
||||
user_agent: null,
|
||||
search: { max_results: 5, timeout: 30 },
|
||||
fetch: { use_jina_reader: true },
|
||||
},
|
||||
image_generation: {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
provider_configured: false,
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "1:1",
|
||||
default_image_size: "1K",
|
||||
max_images_per_turn: 4,
|
||||
save_dir: "generated",
|
||||
providers: [],
|
||||
},
|
||||
runtime: {
|
||||
config_path: "/tmp/config.json",
|
||||
workspace_path: "/tmp/workspace",
|
||||
gateway_host: "127.0.0.1",
|
||||
gateway_port: 18790,
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
interval_s: 1800,
|
||||
keep_recent_messages: 8,
|
||||
},
|
||||
dream: {
|
||||
schedule: "every 2h",
|
||||
max_batch_size: 20,
|
||||
max_iterations: 15,
|
||||
annotate_line_ages: true,
|
||||
},
|
||||
unified_session: false,
|
||||
},
|
||||
advanced: {
|
||||
restrict_to_workspace: false,
|
||||
ssrf_whitelist_count: 0,
|
||||
mcp_server_count: 0,
|
||||
exec_enabled: true,
|
||||
exec_sandbox: null,
|
||||
exec_path_append_set: false,
|
||||
},
|
||||
requires_restart: false,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
|
||||
@@ -709,6 +803,9 @@ describe("App layout", () => {
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
@@ -731,6 +828,7 @@ describe("App layout", () => {
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
|
||||
@@ -822,6 +920,42 @@ describe("App layout", () => {
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
|
||||
fireEvent.click(appsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(document.title).toBe("Apps · nanobot");
|
||||
});
|
||||
|
||||
it("returns from settings to the blank start page when no session was active", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [
|
||||
"models",
|
||||
"image",
|
||||
"web",
|
||||
"apps",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function settingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
has_api_key: true,
|
||||
model_preset: "default",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
timezone: "UTC",
|
||||
bot_name: "nanobot",
|
||||
bot_icon: "nb",
|
||||
tool_hint_max_length: 40,
|
||||
},
|
||||
model_presets: [{
|
||||
name: "default",
|
||||
label: "Default",
|
||||
active: true,
|
||||
is_default: true,
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
providers: [],
|
||||
web_search: {
|
||||
provider: "duckduckgo",
|
||||
api_key_hint: null,
|
||||
base_url: null,
|
||||
max_results: 5,
|
||||
timeout: 30,
|
||||
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
||||
},
|
||||
web: {
|
||||
enable: true,
|
||||
proxy: null,
|
||||
user_agent: null,
|
||||
search: { max_results: 5, timeout: 30 },
|
||||
fetch: { use_jina_reader: true },
|
||||
},
|
||||
image_generation: {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
provider_configured: false,
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "1:1",
|
||||
default_image_size: "1K",
|
||||
max_images_per_turn: 4,
|
||||
save_dir: "generated",
|
||||
providers: [],
|
||||
},
|
||||
runtime: {
|
||||
config_path: "/tmp/config.json",
|
||||
workspace_path: "/tmp/workspace",
|
||||
gateway_host: "127.0.0.1",
|
||||
gateway_port: 18790,
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
interval_s: 1800,
|
||||
keep_recent_messages: 8,
|
||||
},
|
||||
dream: {
|
||||
schedule: "every 2h",
|
||||
max_batch_size: 20,
|
||||
max_iterations: 15,
|
||||
annotate_line_ages: true,
|
||||
},
|
||||
unified_session: false,
|
||||
},
|
||||
advanced: {
|
||||
restrict_to_workspace: false,
|
||||
ssrf_whitelist_count: 0,
|
||||
mcp_server_count: 0,
|
||||
exec_enabled: true,
|
||||
exec_sandbox: null,
|
||||
exec_path_append_set: false,
|
||||
},
|
||||
requires_restart: false,
|
||||
};
|
||||
}
|
||||
|
||||
const installedAnyGen = {
|
||||
name: "anygen",
|
||||
display_name: "AnyGen",
|
||||
category: "generation",
|
||||
description: "Generate docs, slides, websites and more via AnyGen cloud API",
|
||||
requires: "ANYGEN_API_KEY",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-anygen",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
|
||||
brand_color: "#111827",
|
||||
skill_installed: true,
|
||||
};
|
||||
|
||||
function renderSettingsView() {
|
||||
render(
|
||||
<ClientProvider client={{} as never} token="tok">
|
||||
<SettingsView
|
||||
theme="light"
|
||||
initialSection="apps"
|
||||
onToggleTheme={() => {}}
|
||||
onBackToChat={() => {}}
|
||||
onModelNameChange={() => {}}
|
||||
/>
|
||||
</ClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsView Apps catalog", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") {
|
||||
return jsonResponse(settingsPayload());
|
||||
}
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({
|
||||
apps: [installedAnyGen],
|
||||
installed_count: 1,
|
||||
catalog_updated_at: "2026-04-18",
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/cli-apps/uninstall?name=anygen") {
|
||||
return jsonResponse({
|
||||
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
|
||||
installed_count: 0,
|
||||
catalog_updated_at: "2026-04-18",
|
||||
last_action: {
|
||||
ok: true,
|
||||
message: "Uninstalled CLI for AnyGen.",
|
||||
still_available: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
|
||||
const uninstall = screen.getByRole("button", { name: "Uninstall CLI" });
|
||||
|
||||
fireEvent.click(uninstall);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps/uninstall?name=anygen",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -346,7 +346,7 @@ describe("ThreadComposer", () => {
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
|
||||
const palette = screen.getByRole("listbox", { name: "Apps" });
|
||||
expect(palette).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -365,7 +365,7 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
||||
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
|
||||
@@ -1088,7 +1088,7 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
|
||||
|
||||
const payload: CliAppsPayload = {
|
||||
apps: [{
|
||||
@@ -1116,7 +1116,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user