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);
});
}