Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/app/src/components/plugin/PluginThreadChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@ afterEach(() => {
beforeEach(() => {
mocks.embeddedChatProps = [];
mocks.timelinePanelProps = [];
vi.mocked(sdk.threads.get).mockResolvedValue(
THREAD_FIXTURE as never,
);
vi.mocked(sdk.threads.get).mockResolvedValue(THREAD_FIXTURE as never);
});

describe("PluginThreadChat", () => {
Expand Down Expand Up @@ -136,6 +134,7 @@ describe("PluginThreadChat", () => {
expect(props.providerId).toBe("provider_demo");
expect(props.variant).toBe("compact");
expect(props.measure).toBe("panel");
expect(props.surfaceTone).toBe("sidebar");
expect(props.composer).toEqual(
expect.objectContaining({
permissionPolicy: "snapshot",
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/components/plugin/PluginThreadChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ function PluginThreadChatBody({
variant="compact"
layout={layout}
measure={variant === "full" ? "page" : "panel"}
surfaceTone={variant === "compact" ? "sidebar" : "background"}
threadId={threadId}
projectId={thread.projectId}
providerId={thread.providerId}
Expand Down
8 changes: 7 additions & 1 deletion apps/app/src/components/plugin/PluginsOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ function installFetch(plugins: readonly unknown[] = [AUTOMATIONS_PLUGIN]) {
return responseJson({ plugins });
}
if (url.pathname === "/api/v1/plugin-catalog") {
return responseJson({ catalog: { pluginCount: 4 } });
return responseJson({
catalog: {
pluginCount: 13,
includedPluginCount: 9,
optionalPluginCount: 4,
},
});
}
if (url.pathname === "/api/v1/plugin-catalog/search") {
return responseJson({ results: [GITHUB_CATALOG_ENTRY] });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ const MEMORY_ENTRY: PluginCatalogSearchEntry = {
incompatibleReason: null,
};

const CATALOG_STATUS = { pluginCount: 4 };
const CATALOG_STATUS = {
pluginCount: 13,
includedPluginCount: 9,
optionalPluginCount: 4,
};

const INCOMPATIBLE_ENTRY: PluginCatalogSearchEntry = {
...MEMORY_ENTRY,
Expand Down Expand Up @@ -87,6 +91,49 @@ afterEach(() => {
});

describe("BrowsePluginsTab", () => {
it("renders every official catalog card represented by the catalog count", async () => {
const entries = Array.from(
{ length: CATALOG_STATUS.pluginCount },
(_, index) => ({
...MEMORY_ENTRY,
entryId: `official-${index + 1}`,
pluginId: `official-${index + 1}`,
displayName: `Official ${index + 1}`,
category:
index < CATALOG_STATUS.includedPluginCount
? "Included with BB"
: "Productivity",
}),
);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (url === "/api/v1/plugin-catalog") {
return jsonResponse({ catalog: CATALOG_STATUS });
}
if (url === "/api/v1/plugin-catalog/search?q=") {
return jsonResponse({ results: entries });
}
if (url === "/api/v1/plugins") {
return jsonResponse({ enabled: true, plugins: [] });
}
return jsonResponse({ error: "not found" }, 404);
}),
);

const { wrapper } = createQueryClientTestHarness();
render(<BrowsePluginsTab onInstall={() => {}} onOpenPlugin={() => {}} />, {
wrapper,
});

expect(
await screen.findByText("13 plugins · 9 included with BB, 4 optional"),
).toBeTruthy();
expect(
screen.getAllByRole("button", { name: /^Open Official \d+ details$/ }),
).toHaveLength(CATALOG_STATUS.pluginCount);
});

it("shows the official plugins and entries", async () => {
vi.stubGlobal(
"fetch",
Expand Down Expand Up @@ -114,7 +161,9 @@ describe("BrowsePluginsTab", () => {
{ wrapper },
);

expect(await screen.findByText("BB Official plugins")).toBeTruthy();
expect(
await screen.findByText("13 plugins · 9 included with BB, 4 optional"),
).toBeTruthy();
const officialCatalog = screen.getByRole("region", {
name: "BB Official plugins",
});
Expand Down
30 changes: 5 additions & 25 deletions apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDebounceValue } from "usehooks-ts";
import {
RESOURCE_GRID_PAGE_SIZE,
ResourcePagination,
useResourcePagination,
} from "@bb/shared-ui/resource-pagination";
import {
ResourceBrowseCard,
ResourceBrowseGrid,
Expand Down Expand Up @@ -48,13 +43,8 @@ export function BrowsePluginsTab({
const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true });
const status = statusQuery.data;
const entries = searchQuery.data ?? [];
const pagination = useResourcePagination(entries, {
pageSize: RESOURCE_GRID_PAGE_SIZE,
resetKey: debouncedQuery.toLowerCase(),
});

const byCategory = new Map<string, PluginCatalogSearchEntry[]>();
for (const entry of pagination.items) {
for (const entry of entries) {
const bucket = byCategory.get(entry.category);
if (bucket === undefined) byCategory.set(entry.category, [entry]);
else bucket.push(entry);
Expand All @@ -71,18 +61,6 @@ export function BrowsePluginsTab({
onSearchChange={setQuery}
/>
}
footer={
pagination.total > pagination.pageSize ? (
<ResourcePagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
visibleCount={pagination.visibleCount}
onPageChange={pagination.setPage}
scrollTargetId="plugins-browse-results"
/>
) : undefined
}
>
<section
aria-labelledby="bb-official-plugins-heading"
Expand All @@ -104,8 +82,10 @@ export function BrowsePluginsTab({
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} · bundled with BB and
installed with one click
{status.pluginCount === 1 ? "" : "s"} ·{" "}
{status.includedPluginCount}
{" included with BB, "}
{status.optionalPluginCount} optional
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,37 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { PluginSettingsCompatibilityRoute } from "./PluginSettingsCompatibilityRoute";
import { ToolsHubExperimentProvider } from "@/components/tools/tools-experiment-context";

function renderRoute(path: string) {
function renderRoute(path: string, toolsHubEnabled = false) {
render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/settings/plugins"
element={
<PluginSettingsCompatibilityRoute>
<div>Settings plugin manager</div>
</PluginSettingsCompatibilityRoute>
}
/>
<Route
path="/settings/plugins/:pluginId"
element={
<PluginSettingsCompatibilityRoute>
<div>Settings plugin detail</div>
</PluginSettingsCompatibilityRoute>
}
/>
<Route path="/tools/plugins" element={<div>Tools plugins</div>} />
<Route
path="/tools/plugins/:pluginId"
element={<div>Tools plugin detail</div>}
/>
</Routes>
</MemoryRouter>,
<ToolsHubExperimentProvider enabled={toolsHubEnabled}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/settings/plugins"
element={
<PluginSettingsCompatibilityRoute>
<div>Settings plugin manager</div>
</PluginSettingsCompatibilityRoute>
}
/>
<Route
path="/settings/plugins/:pluginId"
element={
<PluginSettingsCompatibilityRoute>
<div>Settings plugin detail</div>
</PluginSettingsCompatibilityRoute>
}
/>
<Route path="/tools/plugins" element={<div>Tools plugins</div>} />
<Route
path="/tools/plugins/:pluginId"
element={<div>Tools plugin detail</div>}
/>
</Routes>
</MemoryRouter>
</ToolsHubExperimentProvider>,
);
}

Expand All @@ -46,10 +49,16 @@ describe("PluginSettingsCompatibilityRoute", () => {
});

it("keeps Settings plugin detail routes available", () => {
renderRoute("/settings/plugins/example");
renderRoute("/settings/plugins/example", true);

expect(screen.getByText("Settings plugin detail")).toBeTruthy();
expect(screen.queryByText("Tools plugin detail")).toBeNull();
});

it("moves legacy plugin management to Extensions while enabled", () => {
renderRoute("/settings/plugins", true);

expect(screen.getByText("Tools plugins")).toBeTruthy();
expect(screen.queryByText("Settings plugin manager")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import type { ReactNode } from "react";
/** Plugin configuration always belongs to Settings, independent of Tools Hub. */
import { Navigate, useLocation } from "react-router-dom";
import { useToolsHubExperiment } from "@/components/tools/tools-experiment-context";
import {
SETTINGS_PLUGINS_ROUTE_PATH,
TOOLS_PLUGINS_ROUTE_PATH,
} from "@/lib/route-paths";

/**
* The Extensions collection replaces legacy plugin management while enabled.
* Plugin-registered settings keep their own Settings routes in both modes.
*/
export function PluginSettingsCompatibilityRoute({
children,
}: {
children: ReactNode;
}) {
const location = useLocation();
const toolsHubEnabled = useToolsHubExperiment();
if (toolsHubEnabled && location.pathname === SETTINGS_PLUGINS_ROUTE_PATH) {
return <Navigate to={TOOLS_PLUGINS_ROUTE_PATH} replace />;
}
return children;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ const MEMORY_ENTRY: PluginCatalogSearchEntry = {
incompatibleReason: null,
};

const CATALOG_STATUS = { pluginCount: 4 };
const CATALOG_STATUS = {
pluginCount: 13,
includedPluginCount: 9,
optionalPluginCount: 4,
};

afterEach(() => {
cleanup();
Expand Down
5 changes: 3 additions & 2 deletions apps/app/src/components/settings/plugins/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export function BrowsePluginsTab({
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} · bundled with BB and
installed with one click
{status.pluginCount === 1 ? "" : "s"} · {status.includedPluginCount}
{" included with BB, "}
{status.optionalPluginCount} optional
</p>
)}
</div>
Expand Down
37 changes: 33 additions & 4 deletions apps/app/src/components/settings/settings-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,31 @@ import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetPluginSlotStoreForTest } from "@/lib/plugin-slots";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { ToolsHubExperimentProvider } from "@/components/tools/tools-experiment-context";
import { useSettingsNavState } from "./settings-nav";

const mocks = vi.hoisted(() => ({
plugins: [] as Array<Record<string, unknown>>,
}));

vi.mock("@/hooks/queries/plugin-settings-queries", () => ({
usePluginList: () => ({ data: { plugins: [] } }),
usePluginList: () => ({ data: { plugins: mocks.plugins } }),
}));

vi.mock("@/hooks/useHostDaemon", () => ({
useHostDaemon: () => ({ hasDaemon: false }),
}));

function wrapperFor(path: string) {
function wrapperFor(path: string, toolsHubEnabled = false) {
const { wrapper: QueryWrapper } = createQueryClientTestHarness();
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryWrapper>
<MemoryRouter initialEntries={[path]}>{children}</MemoryRouter>
<MemoryRouter initialEntries={[path]}>
<ToolsHubExperimentProvider enabled={toolsHubEnabled}>
{children}
</ToolsHubExperimentProvider>
</MemoryRouter>
</QueryWrapper>
);
};
Expand All @@ -31,6 +40,7 @@ afterEach(() => {
cleanup();
resetPluginSlotStoreForTest();
vi.clearAllMocks();
mocks.plugins = [];
});

describe("useSettingsNavState", () => {
Expand Down Expand Up @@ -67,7 +77,7 @@ describe("useSettingsNavState", () => {
);
});

it("keeps plugin management in Settings", () => {
it("keeps legacy plugin management in Settings while Extensions is disabled", () => {
const { result } = renderHook(() => useSettingsNavState(), {
wrapper: wrapperFor("/settings"),
});
Expand All @@ -77,4 +87,23 @@ describe("useSettingsNavState", () => {
);
});

it("hides legacy plugin management but preserves registered plugin settings while Extensions is enabled", () => {
mocks.plugins = [
{
id: "workflows",
enabled: true,
hasSettings: true,
},
];
const { result } = renderHook(() => useSettingsNavState(), {
wrapper: wrapperFor("/settings", true),
});

expect(result.current.sections.map((section) => section.id)).not.toContain(
"plugins",
);
expect(result.current.pluginEntries.map((plugin) => plugin.id)).toEqual([
"workflows",
]);
});
});
Loading
Loading