Skip to content
Merged
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: 4 additions & 1 deletion apps/app/src/components/plugin/PluginsOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ describe("PluginsOverview", () => {

fireEvent.click(screen.getByRole("tab", { name: "Browse" }));
expect(await screen.findByText("GitHub")).toBeTruthy();
expect(screen.getByText("BB Official plugins")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Developer tools" }),
).toBeTruthy();
expect(screen.queryByText("BB Official plugins")).toBeNull();
expect(screen.getByRole("button", { name: "New plugin" })).toBeTruthy();
});

Expand Down
32 changes: 11 additions & 21 deletions apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries";
Expand All @@ -25,7 +24,7 @@ const MEMORY_ENTRY: PluginCatalogSearchEntry = {
displayName: "Memory",
description: "Provider-independent durable memory for agents.",
icon: "Brain",
category: "Productivity",
category: "Context & knowledge",
source: "builtin:memory",
installed: false,
compatible: true,
Expand Down Expand Up @@ -91,15 +90,16 @@ afterEach(() => {
});

describe("BrowsePluginsTab", () => {
it("renders every official catalog card represented by the catalog count", async () => {
it("renders every returned catalog entry exactly once in direct categories", 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 % 2 === 0 ? "Productivity" : "Developer tools",
category:
index % 2 === 0 ? "Context & knowledge" : "Developer tools",
}),
);
vi.stubGlobal(
Expand All @@ -123,15 +123,11 @@ describe("BrowsePluginsTab", () => {
wrapper,
});

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

it("shows the official plugins and entries", async () => {
Expand Down Expand Up @@ -161,12 +157,7 @@ describe("BrowsePluginsTab", () => {
{ wrapper },
);

expect(
await screen.findByText("13 plugins · 9 included with BB, 4 optional"),
).toBeTruthy();
const officialCatalog = screen.getByRole("region", {
name: "BB Official plugins",
});
expect(await screen.findByText("Memory")).toBeTruthy();
const memoryCard = (await screen.findByText("Memory")).closest("div");
expect(memoryCard).not.toBeNull();
// Scoped to the card on purpose: INCOMPATIBLE_ENTRY spreads MEMORY_ENTRY
Expand All @@ -183,16 +174,15 @@ describe("BrowsePluginsTab", () => {
.closest('[class*="auto-fill"]');
expect(githubGrid?.className).toContain("auto-fill");
expect(
within(officialCatalog).getByRole("heading", { name: "Productivity" }),
screen.getByRole("heading", { name: "Context & knowledge" }),
).toBeTruthy();
expect(
within(officialCatalog).getByRole("heading", {
name: "Developer tools",
}),
screen.getByRole("heading", { name: "Developer tools" }),
).toBeTruthy();
expect(
within(officialCatalog).getByRole("button", { name: "Install Memory" }),
screen.getByRole("button", { name: "Install Memory" }),
).toBeTruthy();
expect(screen.queryByText("BB Official plugins")).toBeNull();

expect(screen.queryByText(MEMORY_ENTRY.source)).toBeNull();
expect(screen.getByText("Requires a newer BB version")).toBeTruthy();
Expand Down
130 changes: 45 additions & 85 deletions apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
} from "@/hooks/cache-owners/plugin-cache-owner";
import {
usePluginCatalogSearch,
usePluginCatalogStatus,
type PluginCatalogSearchEntry,
} from "@/hooks/queries/plugin-catalog-queries";
import { removePlugin } from "@/hooks/queries/plugin-settings-queries";
Expand All @@ -39,9 +38,7 @@ export function BrowsePluginsTab({
}) {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebounceValue(query.trim(), 300);
const statusQuery = usePluginCatalogStatus({ enabled: true });
const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true });
const status = statusQuery.data;
const entries = searchQuery.data ?? [];
const byCategory = new Map<string, PluginCatalogSearchEntry[]>();
for (const entry of entries) {
Expand All @@ -62,89 +59,52 @@ export function BrowsePluginsTab({
/>
}
>
<section
aria-labelledby="bb-official-plugins-heading"
className="space-y-4 rounded-lg border border-border bg-card px-3.5 py-3"
>
<div>
<h2
id="bb-official-plugins-heading"
className="text-sm font-medium text-foreground"
>
BB Official plugins
</h2>
{status === undefined ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{statusQuery.isPending
? "Loading plugins…"
: "Plugin list unavailable."}
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} ·{" "}
{status.includedPluginCount}
{" included with BB, "}
{status.optionalPluginCount} optional
</p>
)}
</div>

{searchQuery.isError && entries.length > 0 ? (
<p className="text-xs text-warning-text" role="status">
Showing cached catalog results because the latest search failed.
</p>
) : null}
{searchQuery.isError && entries.length > 0 ? (
<p className="text-xs text-warning-text" role="status">
Showing cached catalog results because the latest search failed.
</p>
) : null}

{searchQuery.isPending ? (
<ResourceListState state="loading" message="Loading plugins" />
) : entries.length === 0 ? (
<ResourceListState
state={searchQuery.isError ? "error" : "empty"}
message={
searchQuery.isError
? "BB's official plugins are unavailable."
: "No plugins match this search."
}
onRetry={
searchQuery.isError
? () => {
void searchQuery.refetch();
}
: undefined
}
/>
) : (
<div className="space-y-4">
{[...byCategory.entries()].map(([category, categoryEntries]) => (
<section
key={category}
aria-labelledby={`plugin-category-${category}`}
>
<h3
id={`plugin-category-${category}`}
className="mb-2 text-sm font-semibold text-foreground"
>
{category}
</h3>
<ResourceBrowseGrid className="grid-cols-[repeat(auto-fill,minmax(min(100%,23rem),1fr))]">
{categoryEntries.map((entry) => (
<BrowseCard
key={entry.entryId}
entry={entry}
installedPluginId={
entry.installed ? entry.pluginId : null
}
onInstall={onInstall}
onOpenPlugin={onOpenPlugin}
/>
))}
</ResourceBrowseGrid>
</section>
))}
</div>
)}
</section>
{searchQuery.isPending ? (
<ResourceListState state="loading" message="Loading plugins" />
) : entries.length === 0 ? (
<ResourceListState
state={searchQuery.isError ? "error" : "empty"}
message={
searchQuery.isError
? "BB's official plugins are unavailable."
: "No plugins match this search."
}
onRetry={
searchQuery.isError
? () => {
void searchQuery.refetch();
}
: undefined
}
/>
) : (
<div className="space-y-5">
{[...byCategory.entries()].map(([category, categoryEntries]) => (
<section key={category} aria-label={category}>
<h2 className="mb-2 text-sm font-semibold text-foreground">
{category}
</h2>
<ResourceBrowseGrid className="grid-cols-[repeat(auto-fill,minmax(min(100%,23rem),1fr))]">
{categoryEntries.map((entry) => (
<BrowseCard
key={entry.entryId}
entry={entry}
installedPluginId={entry.installed ? entry.pluginId : null}
onInstall={onInstall}
onOpenPlugin={onOpenPlugin}
/>
))}
</ResourceBrowseGrid>
</section>
))}
</div>
)}
</ResourceCollectionViewport>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const MEMORY_ENTRY: PluginCatalogSearchEntry = {
displayName: "Memory",
description: "Provider-independent durable memory for agents.",
icon: "Brain",
category: "Productivity",
category: "Context & knowledge",
source: "builtin:memory",
installed: false,
compatible: true,
Expand Down Expand Up @@ -59,7 +59,10 @@ describe("BrowsePluginsTab", () => {
const { wrapper } = createQueryClientTestHarness();
render(<BrowsePluginsTab onInstall={onInstall} />, { wrapper });

expect(await screen.findByText("BB Official plugins")).toBeTruthy();
expect(
await screen.findByRole("heading", { name: "Context & knowledge" }),
).toBeTruthy();
expect(screen.queryByText("BB Official plugins")).toBeNull();
const card = await screen.findByTestId("browse-card-memory");
expect(card.querySelector('[data-icon="Brain"]')).not.toBeNull();

Expand Down
23 changes: 0 additions & 23 deletions apps/app/src/components/settings/plugins/BrowsePluginsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Input } from "@bb/shared-ui/input";
import { pluginIconName } from "@/components/plugin/PluginIcon";
import {
usePluginCatalogSearch,
usePluginCatalogStatus,
type PluginCatalogSearchEntry,
} from "@/hooks/queries/plugin-catalog-queries";
import type { AddPluginInitial } from "@/components/plugin/management/AddPluginDialog";
Expand All @@ -25,9 +24,7 @@ export function BrowsePluginsTab({
}) {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebounceValue(query.trim(), 300);
const statusQuery = usePluginCatalogStatus({ enabled: true });
const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true });
const status = statusQuery.data;
const entries = searchQuery.data ?? [];

const byCategory = new Map<string, PluginCatalogSearchEntry[]>();
Expand All @@ -39,26 +36,6 @@ export function BrowsePluginsTab({

return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card px-3.5 py-3">
<p className="text-sm font-medium text-foreground">
BB Official plugins
</p>
{status === undefined ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{statusQuery.isPending
? "Loading plugins…"
: "Plugin list unavailable."}
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
{status.pluginCount} plugin
{status.pluginCount === 1 ? "" : "s"} · {status.includedPluginCount}
{" included with BB, "}
{status.optionalPluginCount} optional
</p>
)}
</div>

<div className="relative min-w-48">
<Icon
name="Search"
Expand Down
16 changes: 13 additions & 3 deletions apps/server/src/services/plugin-catalog/plugin-catalog-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import {
builtinPluginSource,
listBundledPluginRegistrations,
PLUGIN_CATALOG_CATEGORIES,
type BundledPluginRegistration,
} from "../plugins/builtin-registry.js";
import {
Expand Down Expand Up @@ -40,6 +41,9 @@ export function createPluginCatalogService(deps: {
...plugin,
category: plugin.category ?? "Other",
}));
const categoryOrder = new Map<string, number>(
PLUGIN_CATALOG_CATEGORIES.map((category, index) => [category, index]),
);

// Manifests are read per search so a dev checkout editing a bundled
// plugin's package.json sees fresh store metadata; this small local catalog
Expand Down Expand Up @@ -126,9 +130,15 @@ export function createPluginCatalogService(deps: {
.includes(query),
)
.map(({ result }) => result)
.sort((left, right) =>
left.displayName.localeCompare(right.displayName),
);
.sort((left, right) => {
const categoryDifference =
(categoryOrder.get(left.category) ?? categoryOrder.size) -
(categoryOrder.get(right.category) ?? categoryOrder.size);
return (
categoryDifference ||
left.displayName.localeCompare(right.displayName)
);
});
},

async install(entryId) {
Expand Down
Loading
Loading