From 680ca9928a02820e9cb77091e40189a832ef5bdd Mon Sep 17 00:00:00 2001 From: Andrew Chan Date: Tue, 25 Aug 2026 18:04:31 -0700 Subject: [PATCH] Sort Browse Plugins by install count, and default to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was wrong #2282 published install counts on every Browse card, but the sort menu still offered one option, "Plugin name". The store's only popularity signal was per-card text the user had to scan for, and the grid opened alphabetically — so a widely adopted plugin appeared wherever its name landed. ## What changed `apps/app/src/components/plugin/management/BrowsePluginsTab.tsx`: - The sort menu gains an "Installs" option, and Browse now opens on it, descending: a store's first screen should be the plugins people actually install. Alphabetical stays one click away. - `groupByPublisher` takes the mode. Install order sorts numerically, with entries the sidecar does not name sinking to the bottom in both directions — an unpublished count is unknown, not zero — and names breaking ties so equally installed plugins stay stable. - Only the curated marketplace publishes counts, so a catalog with none disables the option and falls back to alphabetical *ascending*, rather than inheriting the count sort's descending direction and showing an unexplained Z→A grid. `changeSort` compares against the mode on screen, so the checked row always toggles direction. No wire, CLI, or doc surface changes: this is a view affordance over data the API already returns, and `bb plugin search` already prints an Installs column (`apps/cli/src/commands/plugin.ts:918`). ## How you verified Two tests in `BrowsePluginsTab.test.tsx`, both failing before this change: install-count ordering (default mode and direction on first render, the uncounted entry pinned last in both directions, and the reset to ascending when switching back to names), and the disabled option plus alphabetical fallback when no listing publishes a count. - `pnpm exec turbo run test --filter=@bb/app -- BrowsePluginsTab` — 13/13 - `pnpm exec turbo run typecheck --filter=@bb/app` — clean > AGENT GENERATED --- .../management/BrowsePluginsTab.test.tsx | 148 ++++++++++++++++++ .../plugin/management/BrowsePluginsTab.tsx | 78 +++++++-- 2 files changed, 214 insertions(+), 12 deletions(-) diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx index bd983ef842..dd39a6ea8b 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx @@ -300,6 +300,154 @@ describe("BrowsePluginsTab", () => { expect(screen.queryByText("0 installs")).toBeNull(); }); + it("sorts by install count, sinking uncounted entries in both directions", async () => { + const entries = [ + { + ...MEMORY_ENTRY, + entryId: "mid", + pluginId: "mid", + displayName: "Mid", + installs: 50, + }, + { + ...MEMORY_ENTRY, + entryId: "top", + pluginId: "top", + displayName: "Top", + installs: 900, + }, + { + ...MEMORY_ENTRY, + entryId: "unknown", + pluginId: "unknown", + displayName: "Unknown", + installs: null, + }, + ]; + 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( + + {}} + onOpenPlugin={() => {}} + onInstallFromSource={() => {}} + /> + , + { wrapper }, + ); + + await screen.findByText("Top"); + const cardOrder = () => + [ + ...document.querySelectorAll( + 'button[aria-label^="Open "][aria-label$=" details"]', + ), + ].map((button) => button.getAttribute("aria-label")); + + // Open the sort menu once and keep it open: selecting an option preserves + // the menu, and Radix hides the rest of the tree from the accessibility + // API while it is, so the trigger is captured before the first click. + const sortTrigger = screen.getByRole("button", { name: /^Sort: / }); + // Browse lands on popularity, most installed first; the uncounted entry is + // unknown, not zero, so it sits last rather than at the bottom of the + // count order. + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Installs, descending", + ); + expect(cardOrder()).toEqual([ + "Open Top details", + "Open Mid details", + "Open Unknown details", + ]); + + fireEvent.pointerDown(sortTrigger); + const selectSort = (name: string) => { + fireEvent.click(screen.getByRole("menuitemradio", { name })); + }; + + // Re-picking the mode already showing flips direction: fewest first. + selectSort("Installs"); + expect(cardOrder()).toEqual([ + "Open Mid details", + "Open Top details", + "Open Unknown details", + ]); + + // Switching back to names restores A→Z, not the reversed direction the + // install sort was left in. + selectSort("Plugin name"); + expect(cardOrder()).toEqual([ + "Open Mid details", + "Open Top details", + "Open Unknown details", + ]); + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Plugin name, ascending", + ); + }); + + it("disables the install sort when no listing publishes a count", async () => { + const entries = [ + { ...MEMORY_ENTRY, displayName: "Memory" }, + { ...GITHUB_ENTRY, displayName: "GitHub" }, + ]; + 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( + + {}} + onOpenPlugin={() => {}} + onInstallFromSource={() => {}} + /> + , + { wrapper }, + ); + + await screen.findByText("Memory"); + const sortTrigger = screen.getByRole("button", { name: /^Sort: / }); + fireEvent.pointerDown(sortTrigger); + const installsItem = screen.getByRole("menuitemradio", { + name: "Installs", + }); + expect(installsItem.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(installsItem); + expect(sortTrigger.getAttribute("aria-label")).toBe( + "Sort: Plugin name, ascending", + ); + }); + it("keeps a marketplace that copies a publisher label in its own group", async () => { const entries = [ { ...MEMORY_ENTRY, displayName: "Memory" }, diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx index 9a8bb5bcb9..edac2a86d8 100644 --- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx +++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx @@ -104,7 +104,10 @@ export function BrowsePluginsTab({ }, [heroRequest]); // Empty means unfiltered, matching the Type filters on Installed and Skills. const [categories, setCategories] = useState([]); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + // Browse is a store, so it opens on popularity: the most installed plugins + // are the ones a first visit should see. Alphabetical stays one click away. + const [sortMode, setSortMode] = useState("installs"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [debouncedQuery] = useDebounceValue(query.trim(), 300); const searchQuery = usePluginCatalogSearch(debouncedQuery, { enabled: true }); // Browse offers installs, so an entry this BB cannot install is noise here. @@ -126,11 +129,39 @@ export function BrowsePluginsTab({ id: name, label: name, })); + // Only the curated marketplace publishes counts, so a catalog without a + // single count has nothing to order by; offering the mode would sort the + // grid by name and look broken. + const installsKnown = entries.some((entry) => entry.installs !== null); + // Falling back carries the fallback's own default direction: the count sort + // opens descending, and inheriting that would show an unexplained Z→A grid. + const effectiveSortMode = + sortMode === "installs" && !installsKnown ? "alpha" : sortMode; + const effectiveSortDirection = + effectiveSortMode === sortMode ? sortDirection : "asc"; + // Picking the mode already showing flips direction, as on the other + // collections. A new mode starts at the direction that reads as its default: + // A→Z for names, most-installed-first for popularity. The comparison is + // against the mode on screen, so the menu's checked row always toggles. + const changeSort = (next: string) => { + if (next !== "alpha" && next !== "installs") return; + if (next === effectiveSortMode) { + setSortDirection(effectiveSortDirection === "asc" ? "desc" : "asc"); + setSortMode(next); + return; + } + setSortMode(next); + setSortDirection(next === "installs" ? "desc" : "asc"); + }; const visibleEntries = categories.length === 0 ? entries : entries.filter((entry) => categories.includes(entry.category)); - const groups = groupByPublisher(visibleEntries, sortDirection); + const groups = groupByPublisher( + visibleEntries, + effectiveSortMode, + effectiveSortDirection, + ); // A single group needs no heading — with nothing to contrast against, naming // it would add page chrome that tells the user nothing. Bundled plugins and // the curated marketplace are two publishers, so in practice headings show. @@ -211,15 +242,18 @@ export function BrowsePluginsTab({ /> ) : null} - setSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } + options={[ + { id: "alpha", label: "Plugin name" }, + { + id: "installs", + label: "Installs", + disabled: !installsKnown, + }, + ]} + onChange={changeSort} /> } @@ -298,6 +332,8 @@ export function BrowsePluginsTab({ ); } +type BrowseSortMode = "alpha" | "installs"; + interface PublisherGroup { key: string; label: string; @@ -322,6 +358,7 @@ interface PublisherGroup { */ function groupByPublisher( entries: readonly PluginCatalogSearchEntry[], + sortMode: BrowseSortMode, sortDirection: "asc" | "desc", ): PublisherGroup[] { const groups: PublisherGroup[] = []; @@ -340,8 +377,25 @@ function groupByPublisher( } for (const group of groups) { group.entries.sort((left, right) => { - const result = left.displayName.localeCompare(right.displayName); - if (result !== 0) return sortDirection === "asc" ? result : -result; + if (sortMode === "installs") { + // An entry the sidecar does not name has an unknown count, not zero, + // so it sinks to the bottom in both directions rather than claiming + // either end of the popularity order. + if (left.installs === null || right.installs === null) { + if (left.installs !== null) return -1; + if (right.installs !== null) return 1; + } else if (left.installs !== right.installs) { + const result = left.installs - right.installs; + return sortDirection === "asc" ? result : -result; + } + } else { + const result = left.displayName.localeCompare(right.displayName); + if (result !== 0) return sortDirection === "asc" ? result : -result; + } + // Names break count ties so equally installed plugins stay in a stable, + // readable order instead of the server's arbitrary one. + const byName = left.displayName.localeCompare(right.displayName); + if (byName !== 0) return byName; return left.entryId.localeCompare(right.entryId); }); }