From bdcd0977bd451a1b75ed98178e510e9f092dbc0f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 28 Jul 2026 20:14:42 -0700 Subject: [PATCH 1/2] Improve Tools Hub filtering and plugin navigation --- .../plugin/PluginsOverview.test.tsx | 30 ++++ .../src/components/plugin/PluginsOverview.tsx | 54 +++++-- .../PluginSettingsCompatibilityRoute.test.tsx | 13 +- .../PluginSettingsCompatibilityRoute.tsx | 22 +-- .../components/settings/settings-nav.test.tsx | 19 ++- .../src/components/settings/settings-nav.tsx | 15 +- .../components/tools/PluginCapabilities.tsx | 136 ++++++++++++------ .../app/src/components/tools/PluginDetail.tsx | 17 ++- .../src/components/tools/SkillsCollection.tsx | 45 +++++- .../tools/detail-page-recipes.test.tsx | 48 ++++++- apps/app/src/lib/route-paths.ts | 6 +- apps/app/src/views/SkillsView.test.tsx | 55 ++++++- apps/server/src/services/plugins/manifest.ts | 47 ++++-- .../src/services/plugins/plugin-service.ts | 12 +- .../services/plugins/plugin-manifest.test.ts | 16 +++ .../services/plugins/plugin-service.test.ts | 16 ++- 16 files changed, 422 insertions(+), 129 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 488b1474a..33f732b9d 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -414,6 +414,36 @@ describe("PluginsOverview", () => { expect(builtInPills[0]?.parentElement?.className).toContain("py-0"); }); + it("defaults to all plugins and can filter to built-in bb plugins", async () => { + installFetch(true, [ + AUTOMATIONS_PLUGIN, + { + ...AUTOMATIONS_PLUGIN, + id: "local-plugin", + name: "Local plugin", + source: "path:/plugins/local-plugin", + provenance: "direct", + }, + ]); + const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + + expect(await screen.findByText("Automations")).toBeTruthy(); + expect(screen.getByText("Local plugin")).toBeTruthy(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Source" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Built-in bb" })); + + expect(screen.getByText("Automations")).toBeTruthy(); + expect(screen.queryByText("Local plugin")).toBeNull(); + }); + it("uses the same passive provenance tag for built-in and BB Official plugins", async () => { installFetch(true, [ AUTOMATIONS_PLUGIN, diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 9d52a4554..40a023908 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -9,6 +9,7 @@ import { ResourceCollectionPage, ResourceCollectionViewport, ResourceListState, + ResourceOptionMenu, ResourceSortMenu, ResourceToolbar, type ResourceCollectionMode, @@ -31,6 +32,7 @@ import { } from "@/lib/route-paths"; type PluginsCollectionMode = "installed" | "browse"; +type PluginSourceFilter = "all" | "builtin"; function modeFromSearchParams( value: string | null, @@ -69,6 +71,7 @@ export function PluginsOverview() { const [installedSortDirection, setInstalledSortDirection] = useState< "asc" | "desc" >("asc"); + const [sourceFilter, setSourceFilter] = useState("all"); const [addDialog, setAddDialog] = useState<{ open: boolean; initial: AddPluginInitial | null; @@ -92,6 +95,9 @@ export function PluginsOverview() { () => plugins .filter((plugin) => { + if (sourceFilter === "builtin" && plugin.provenance !== "builtin") { + return false; + } if (normalizedInstalledQuery.length === 0) return true; return [ plugin.id, @@ -114,11 +120,15 @@ export function PluginsOverview() { ); return installedSortDirection === "asc" ? result : -result; }), - [installedSortDirection, normalizedInstalledQuery, plugins], + [installedSortDirection, normalizedInstalledQuery, plugins, sourceFilter], ); const installedPagination = useResourcePagination(visiblePlugins, { pageSize: installedPageSize, - resetKey: [normalizedInstalledQuery, installedSortDirection].join("\u0000"), + resetKey: [ + normalizedInstalledQuery, + sourceFilter, + installedSortDirection, + ].join("\u0000"), }); const hasInstalledPagination = !listQuery.isError && @@ -188,16 +198,30 @@ export function PluginsOverview() { onSearchChange={setInstalledQuery} containedControls controls={ - - setInstalledSortDirection((current) => - current === "asc" ? "desc" : "asc", - ) - } - /> + <> + + setSourceFilter(value as PluginSourceFilter) + } + /> + + setInstalledSortDirection((current) => + current === "asc" ? "desc" : "asc", + ) + } + /> + } /> } @@ -226,7 +250,11 @@ export function PluginsOverview() { ) : plugins.length > 0 && visiblePlugins.length === 0 ? ( 0 + ? `No plugins match "${installedQuery}"` + : "No plugins match this filter." + } /> ) : ( diff --git a/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.test.tsx b/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.test.tsx index ab287f3e8..d4ffa844f 100644 --- a/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.test.tsx +++ b/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.test.tsx @@ -74,11 +74,18 @@ describe("PluginSettingsCompatibilityRoute", () => { expect(screen.queryByText("Tools plugin detail")).toBeNull(); }); - it("redirects legacy Settings plugin routes to Tools Hub when enabled", () => { + it("keeps plugin configuration in Settings while Tools Hub is enabled", () => { renderRoute("/settings/plugins/example", true); - expect(screen.getByText("Tools plugin detail")).toBeTruthy(); - expect(screen.queryByText("Settings plugin detail")).toBeNull(); + expect(screen.getByText("Settings plugin detail")).toBeTruthy(); + expect(screen.queryByText("Tools plugin detail")).toBeNull(); + }); + + it("redirects the legacy Settings plugin manager to Tools Hub when enabled", () => { + renderRoute("/settings/plugins", true); + + expect(screen.getByText("Tools plugins")).toBeTruthy(); + expect(screen.queryByText("Settings plugin manager")).toBeNull(); }); it("renders neither management surface while configuration is loading", () => { diff --git a/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.tsx b/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.tsx index 2092476a9..6905c1991 100644 --- a/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.tsx +++ b/apps/app/src/components/settings/PluginSettingsCompatibilityRoute.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from "react"; import { Navigate, useParams } from "react-router-dom"; import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { - getPluginDetailRoutePath, - getPluginsRoutePath, -} from "@/lib/route-paths"; +import { getPluginsRoutePath } from "@/lib/route-paths"; -/** Keeps the existing Settings manager available while Tools Hub is off. */ +/** + * Replaces the legacy plugin manager with Tools Hub while preserving each + * plugin's Settings page. + */ export function PluginSettingsCompatibilityRoute({ children, }: { @@ -18,15 +18,7 @@ export function PluginSettingsCompatibilityRoute({ if (toolsHubEnabled === undefined) return null; if (!toolsHubEnabled) return children; + if (pluginId !== undefined) return children; - return ( - - ); + return ; } diff --git a/apps/app/src/components/settings/settings-nav.test.tsx b/apps/app/src/components/settings/settings-nav.test.tsx index 71d31d53a..7be8d962f 100644 --- a/apps/app/src/components/settings/settings-nav.test.tsx +++ b/apps/app/src/components/settings/settings-nav.test.tsx @@ -10,6 +10,7 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { useSettingsNavState } from "./settings-nav"; const mocks = vi.hoisted(() => ({ + usePluginList: vi.fn(), useSystemConfig: vi.fn(), })); @@ -18,7 +19,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ })); vi.mock("@/hooks/queries/plugin-settings-queries", () => ({ - usePluginList: () => ({ data: { plugins: [] } }), + usePluginList: mocks.usePluginList, })); vi.mock("@/hooks/useHostDaemon", () => ({ @@ -43,6 +44,7 @@ afterEach(() => { }); beforeEach(() => { + mocks.usePluginList.mockReturnValue({ data: { plugins: [] } }); mocks.useSystemConfig.mockReset(); mocks.useSystemConfig.mockReturnValue({ data: { @@ -109,6 +111,17 @@ describe("useSettingsNavState", () => { }); it("removes plugin management from Settings while Tools Hub is enabled", () => { + mocks.usePluginList.mockReturnValue({ + data: { + plugins: [ + { + id: "connect", + enabled: true, + hasSettings: true, + }, + ], + }, + }); mocks.useSystemConfig.mockReturnValue({ data: { experiments: { @@ -125,6 +138,8 @@ describe("useSettingsNavState", () => { expect(result.current.sections.map((section) => section.id)).not.toContain( "plugins", ); - expect(result.current.pluginEntries).toEqual([]); + expect(result.current.pluginEntries.map((plugin) => plugin.id)).toEqual([ + "connect", + ]); }); }); diff --git a/apps/app/src/components/settings/settings-nav.tsx b/apps/app/src/components/settings/settings-nav.tsx index 66ed8788e..efff43a71 100644 --- a/apps/app/src/components/settings/settings-nav.tsx +++ b/apps/app/src/components/settings/settings-nav.tsx @@ -88,8 +88,7 @@ export function useSettingsNavState(): SettingsNavState { settingsSections.map((section) => section.pluginId), ); const pluginListQuery = usePluginList({ - enabled: - !toolsHubEnabled && (pluginsEnabled || settingsSectionPluginIds.size > 0), + enabled: pluginsEnabled || settingsSectionPluginIds.size > 0, }); const pluginMatch = matchPath(SETTINGS_PLUGIN_ROUTE_PATH, location.pathname); @@ -130,13 +129,11 @@ export function useSettingsNavState(): SettingsNavState { } return true; }); - const pluginEntries = toolsHubEnabled - ? [] - : (pluginListQuery.data?.plugins ?? []).filter( - (plugin) => - plugin.enabled && - (plugin.hasSettings || settingsSectionPluginIds.has(plugin.id)), - ); + const pluginEntries = (pluginListQuery.data?.plugins ?? []).filter( + (plugin) => + plugin.enabled && + (plugin.hasSettings || settingsSectionPluginIds.has(plugin.id)), + ); return { activePluginId, activeProviderId, diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx index 45e0043a6..098226df5 100644 --- a/apps/app/src/components/tools/PluginCapabilities.tsx +++ b/apps/app/src/components/tools/PluginCapabilities.tsx @@ -92,26 +92,17 @@ interface PluginCapabilityItem { mono?: boolean; } -function capabilityDetail(kind: string, id?: string): ReactNode { - return ( - - {kind} - {id ? {id} : null} - - ); -} - function namedSurface( prefix: string, id: string, title: string | undefined, - kind: string, + describe: (label: string) => string, ): PluginCapabilityItem { const label = title?.trim() || id; return { key: `${prefix}:${id}`, label, - detail: capabilityDetail(kind, label === id ? undefined : id), + detail: describe(label), mono: label === id, }; } @@ -120,28 +111,49 @@ function namedSlotItems( pluginId: string, slots: readonly { pluginId: string; id: string; title?: string }[], prefix: string, - kind: string, + describe: (label: string) => string, ): PluginCapabilityItem[] { return slots .filter((slot) => slot.pluginId === pluginId) - .map((slot) => namedSurface(prefix, slot.id, slot.title, kind)); + .map((slot) => namedSurface(prefix, slot.id, slot.title, describe)); } function pluginAppSurfaceItems( - pluginId: string, + plugin: PluginListItem, slots: PluginSlotSnapshot, ): PluginCapabilityItem[] { + const pluginId = plugin.id; const namedSlots = [ - [slots.navPanels, "nav", "Navigation panel"], - [slots.homepageSections, "homepage", "Homepage section"], - [slots.threadPanelActions, "thread-panel", "Thread panel action"], - [slots.pendingInteractions, "input", "Input renderer"], - [slots.sidebarFooterActions, "sidebar", "Sidebar action"], - [slots.messageActions, "message-action", "Message action"], + [slots.navPanels, "nav", (label: string) => `Open ${label} in bb.`], + [ + slots.homepageSections, + "homepage", + (label: string) => `Show ${label} on the homepage.`, + ], + [ + slots.threadPanelActions, + "thread-panel", + (label: string) => `Open ${label} from a thread.`, + ], + [ + slots.pendingInteractions, + "input", + (label: string) => `Collect input for ${label}.`, + ], + [ + slots.sidebarFooterActions, + "sidebar", + (label: string) => `Open ${label} from the sidebar.`, + ], + [ + slots.messageActions, + "message-action", + (label: string) => `Run ${label} on a thread message.`, + ], ] as const; return [ - ...namedSlots.flatMap(([items, prefix, kind]) => - namedSlotItems(pluginId, items, prefix, kind), + ...namedSlots.flatMap(([items, prefix, describe]) => + namedSlotItems(pluginId, items, prefix, describe), ), ...slots.composerCustomizations .filter((slot) => slot.pluginId === pluginId) @@ -151,7 +163,7 @@ function pluginAppSurfaceItems( `composer:${slot.id}:action`, action.id, undefined, - "Composer action", + (label) => `Run ${label} from the composer.`, ), ), ...(slot.banners ?? []).map((banner) => @@ -159,7 +171,7 @@ function pluginAppSurfaceItems( `composer:${slot.id}:banner`, banner.id, undefined, - "Composer banner", + (label) => `Show ${label} above the composer.`, ), ), ...(slot.plusMenu ?? []).map((item) => @@ -167,7 +179,9 @@ function pluginAppSurfaceItems( `composer:${slot.id}:plus-menu`, item.id, item.label, - "Composer plus-menu item", + (label) => + item.description ?? + `Add ${label.toLowerCase()} from the composer.`, ), ), ...(slot.richText?.effects ?? []).map((effect) => @@ -175,21 +189,21 @@ function pluginAppSurfaceItems( `composer:${slot.id}:rich-text`, effect.id, undefined, - "Composer text effect", + (label) => `Apply ${label} while composing.`, ), ), ]), ...slots.fileOpeners .filter((slot) => slot.pluginId === pluginId) .map((slot) => ({ - ...namedSurface("file", slot.id, slot.title, "File opener"), - detail: ( - - File opener - - {slot.extensions.map((extension) => `.${extension}`).join(", ")} - - + ...namedSurface( + "file", + slot.id, + slot.title, + (label) => + `Open ${slot.extensions + .map((extension) => `.${extension}`) + .join(", ")} files with ${label}.`, ), })), ...slots.messageDirectives @@ -197,12 +211,29 @@ function pluginAppSurfaceItems( .map((slot) => ({ key: `directive:${slot.id}`, label: `::${slot.id}`, - detail: "Message renderer", + detail: `Render ::${slot.id} content inside assistant messages.`, mono: true, })), ]; } +function pluginServiceDescription(plugin: PluginListItem): string { + const name = plugin.name ?? plugin.id; + return plugin.description + ? `Runs in the background. ${plugin.description}` + : `Runs ${name} work in the background.`; +} + +function pluginScheduleDescription( + plugin: PluginListItem, + cron: string, +): string { + const purpose = + plugin.description ?? + `Runs scheduled work for ${plugin.name ?? plugin.id}.`; + return `Runs on ${cron}. ${purpose}`; +} + function PluginCapabilityGroup({ icon, label, @@ -263,7 +294,7 @@ export function PluginIncludes({ const settingsSections = slots.settingsSections.filter( (slot) => slot.pluginId === plugin.id, ); - const appItems = pluginAppSurfaceItems(plugin.id, slots); + const appItems = pluginAppSurfaceItems(plugin, slots); if ( plugin.app.hasApp && appItems.length === 0 && @@ -272,7 +303,9 @@ export function PluginIncludes({ appItems.push({ key: "frontend-app", label: "Frontend app", - detail: "Surface names are available while the plugin app is loaded", + detail: + plugin.description ?? + `Provides ${plugin.name ?? plugin.id} screens while its app is loaded.`, }); } @@ -281,17 +314,20 @@ export function PluginIncludes({ ([key, descriptor]) => ({ key: `setting:${key}`, label: descriptor.label, - detail: capabilityDetail("Setting", key), + detail: + descriptor.description ?? + `Configure ${descriptor.label.toLowerCase()}.`, }), ), - ...settingsSections.map((slot) => - namedSurface( + ...settingsSections.map((slot) => { + const item = namedSurface( "settings-section", slot.id, slot.title, - "Custom settings section", - ), - ), + (label) => slot.description ?? `Configure ${label} in Settings.`, + ); + return item; + }), ]; if (hasSettings && settingsItems.length === 0) { settingsItems.push({ @@ -309,7 +345,13 @@ export function PluginIncludes({ .map((capability) => ({ key: `${capability.kind}:${capability.id}`, label: capability.label, - detail: capability.detail ?? undefined, + detail: + capability.detail ?? + (kind === "theme" + ? `Apply the ${capability.label} theme to bb.` + : kind === "skill" + ? `Teach agents how to use ${capability.label}.` + : `Use ${capability.label} in bb.`), mono: kind === "skill" || kind === "agent-tool", })); @@ -348,7 +390,7 @@ export function PluginIncludes({ items: plugin.services.map((service) => ({ key: service.name, label: service.name, - detail: "Background service", + detail: pluginServiceDescription(plugin), mono: true, })), }, @@ -358,7 +400,7 @@ export function PluginIncludes({ items: plugin.schedules.map((schedule) => ({ key: schedule.name, label: schedule.name, - detail: capabilityDetail("Cron", schedule.cron), + detail: pluginScheduleDescription(plugin, schedule.cron), mono: true, })), }, @@ -484,7 +526,7 @@ export function PluginActivity({ > {service.name} - Background service + {pluginServiceDescription(plugin)} ))} diff --git a/apps/app/src/components/tools/PluginDetail.tsx b/apps/app/src/components/tools/PluginDetail.tsx index 07f6f423c..ebecd07ca 100644 --- a/apps/app/src/components/tools/PluginDetail.tsx +++ b/apps/app/src/components/tools/PluginDetail.tsx @@ -1,6 +1,8 @@ import { useSyncExternalStore } from "react"; +import { Link } from "react-router-dom"; import { ResourceActivitySection, + ResourceDetailActionRow, ResourceDetailConfigurationSection, ResourceDetailFact, ResourceDetailFacts, @@ -14,8 +16,8 @@ import { ResourceListState, ResourceOverflowMenu, } from "@bb/shared-ui/resource-list"; +import { Button } from "@bb/shared-ui/button"; import { Switch } from "@bb/shared-ui/switch"; -import { PluginSettingsDetail } from "@/components/plugin/PluginSettings"; import { PluginReleaseFacts, PluginUpdateBanner, @@ -33,6 +35,7 @@ import { subscribePluginFrontendDiagnostics, } from "@/lib/plugin-frontend"; import { usePluginSlots } from "@/lib/plugin-slots"; +import { getSettingsPluginRoutePath } from "@/lib/route-paths"; function pluginSourceLabel(plugin: PluginListItem): string | null { if (plugin.provenance === "builtin") return "Built-in"; @@ -207,7 +210,17 @@ export function PluginDetail({ {hasSettings ? ( - + + + Open Settings + + + } + /> ) : null} {hasActivity ? ( diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 1301d8d68..7e650b926 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -12,6 +12,7 @@ import { ResourceListPanel, ResourceListState, ResourceMultiSelectMenu, + ResourceOptionMenu, ResourceOverflowMenu, ResourceRow, ResourceRowDetailChevron, @@ -34,6 +35,7 @@ import { } from "@/lib/provider-icon"; type ResourceProviderFilter = "bb" | SkillProvider; +type ResourceSkillSourceFilter = "all" | "bb-builtin" | "plugin"; type ResourceSortMode = "provider" | "alpha"; type ResourceSortDirection = "asc" | "desc"; @@ -113,6 +115,10 @@ function includedPluginDescription(skill: SkillSummary): string { return `${providerPluginDisplayName(skill)} (${providerLabel(skill.provider)} plugin)`; } +function pluginProvenanceLabel(skill: SkillSummary): string { + return `${providerPluginDisplayName(skill)} · ${providerLabel(skill.provider)} plugin`; +} + function skillMutationDisabledReason(skill: SkillSummary): string { if (skill.scope === "bb-builtin") return "Built-in skill"; if (skill.scope === "plugin") return "Bundled with plugin"; @@ -134,6 +140,8 @@ function SkillRow({ titleMeta={ skill.scope === "bb-builtin" ? ( + ) : skill.scope === "plugin" ? ( + ) : undefined } description={description} @@ -180,7 +188,9 @@ export function SkillsOverview({ }: SkillsOverviewProps) { const [providerFilters, setProviderFilters] = useState< ResourceProviderFilter[] - >([]); + >(["bb"]); + const [sourceFilter, setSourceFilter] = + useState("all"); const [sortMode, setSortMode] = useState("alpha"); const [sortDirection, setSortDirection] = useState("asc"); @@ -206,6 +216,7 @@ export function SkillsOverview({ })); }, [providerCounts]); useEffect(() => { + if (providerCounts.size === 0) return; setProviderFilters((current) => current.filter((provider) => providerCounts.has(provider)), ); @@ -224,6 +235,9 @@ export function SkillsOverview({ ) { return false; } + if (sourceFilter !== "all" && skill.scope !== sourceFilter) { + return false; + } return ( normalizedQuery === "" || [ @@ -246,12 +260,20 @@ export function SkillsOverview({ : left.name.localeCompare(right.name); return sortDirection === "asc" ? base : -base; }); - }, [normalizedQuery, providerFilters, skills, sortDirection, sortMode]); + }, [ + normalizedQuery, + providerFilters, + skills, + sortDirection, + sortMode, + sourceFilter, + ]); const libraryPagination = useResourcePagination(visibleSkills, { pageSize: libraryPageSize, resetKey: [ normalizedQuery, providerFilters.join(","), + sourceFilter, sortMode, sortDirection, ].join("\u0000"), @@ -285,10 +307,12 @@ export function SkillsOverview({ @@ -348,6 +372,19 @@ export function SkillsOverview({ setProviderFilters(values as ResourceProviderFilter[]) } /> + + setSourceFilter(value as ResourceSkillSourceFilter) + } + /> { kind: "skill", id: "review", label: "review", - detail: "Skill this plugin adds to your agents", + detail: "Review code for correctness and maintainability.", }, { kind: "theme", @@ -154,7 +154,7 @@ describe("Plugin detail recipe", () => { kind: "thread-integration", id: "mention:pr", label: "Pull requests", - detail: "Mentions with #", + detail: "Reference pull requests in prompts with #.", }, ], }); @@ -173,6 +173,12 @@ describe("Plugin detail recipe", () => { expect(group, `no group rendered for ${heading}`).not.toBeNull(); expect(within(group as HTMLElement).getByText(item)).toBeTruthy(); } + expect( + screen.getByText("Review code for correctness and maintainability."), + ).toBeTruthy(); + expect( + screen.getByText("Reference pull requests in prompts with #."), + ).toBeTruthy(); }); it("keeps browser-registered app surfaces in Includes", () => { @@ -197,6 +203,44 @@ describe("Plugin detail recipe", () => { expect(screen.getByText("App surfaces")).toBeTruthy(); expect(screen.getByText("Issues")).toBeTruthy(); + expect(screen.getByText("Open Issues in bb.")).toBeTruthy(); + }); + + it("describes background capabilities by purpose and timing", () => { + renderPlugin({ + ...PLUGIN, + services: [{ name: "github-sync", state: "running" }], + schedules: [ + { + name: "issue-refresh", + cron: "0 * * * *", + lastRunAt: null, + nextRunAt: Date.parse("2026-07-29T00:00:00.000Z"), + lastStatus: null, + lastError: null, + }, + ], + }); + + expect( + screen.getAllByText( + "Runs in the background. Browse GitHub issues and pull requests in BB.", + ), + ).toHaveLength(2); + expect( + screen.getByText( + "Runs on 0 * * * *. Browse GitHub issues and pull requests in BB.", + ), + ).toBeTruthy(); + expect(screen.queryByText("Background service")).toBeNull(); + }); + + it("links Settings to the plugin's configuration page", () => { + renderPlugin({ ...PLUGIN, hasSettings: true }); + + expect( + screen.getByRole("link", { name: "Open Settings" }).getAttribute("href"), + ).toBe("/settings/plugins/github"); }); it("explains an empty Includes instead of dropping the section", () => { diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts index a4468de11..2cb581a10 100644 --- a/apps/app/src/lib/route-paths.ts +++ b/apps/app/src/lib/route-paths.ts @@ -4,9 +4,9 @@ import { matchPath } from "react-router-dom"; export const APP_ROOT_ROUTE_PATH = "/"; export const AUTH_CALLBACK_ROUTE_PATH = "/auth/callback"; export const SETTINGS_ROUTE_PATH = "/settings"; -// Settings buckets (general, files, …) plus legacy plugin routes that redirect -// to the canonical Tools → Plugins surfaces. The static "plugins" segment must -// win over :section so those old deep links resolve before redirecting. +// Settings buckets (general, files, …) plus plugin settings routes. The static +// "plugins" segment must win over :section so the plugin manager can redirect +// to Tools Hub while individual plugin configuration remains in Settings. export const SETTINGS_SECTION_ROUTE_PATH = "/settings/:section"; export const SETTINGS_PLUGINS_ROUTE_PATH = "/settings/plugins"; export const SETTINGS_PLUGIN_ROUTE_PATH = "/settings/plugins/:pluginId"; diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index f56693489..a9fbb53e5 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -233,7 +233,7 @@ function renderRegistrySkillRoute() { } describe("SkillsOverview", () => { - it("renders flat rows with provider filter and sort controls", () => { + it("defaults to bb skills and renders both filter controls", () => { const markup = render({ skills: [ makeSkill({ name: "claude-skill", provider: "claude-code" }), @@ -245,9 +245,9 @@ describe("SkillsOverview", () => { }), ], }); - expect(markup).toContain("claude-skill"); expect(markup).toContain("Review the current diff."); - expect(markup).toContain('aria-label="Agent"'); + expect(markup).toContain('aria-label="Agent: 1 selected"'); + expect(markup).toContain('aria-label="Source"'); expect(markup).toContain("Sort"); expect(markup).toContain('role="tab"'); expect(markup).toContain("Library"); @@ -255,12 +255,57 @@ describe("SkillsOverview", () => { expect(markup).toContain("Built-in"); expect(markup).toContain("New bb skill"); expect(markup).not.toContain('aria-label="Open bb-skill"'); + expect(markup).not.toContain("claude-skill"); expect(markup.indexOf("Library")).toBeLessThan( markup.indexOf('placeholder="Search skills"'), ); - expect(markup.indexOf("bb-skill")).toBeLessThan( - markup.indexOf("claude-skill"), + }); + + it("filters built-in and plugin-bundled bb skills by source", async () => { + renderDom( + {}} + onSelectSkill={() => {}} + />, ); + + expect(screen.getByText("library-skill")).toBeTruthy(); + expect(screen.getByText("bb-cli")).toBeTruthy(); + expect(screen.getByText("automations")).toBeTruthy(); + expect(screen.getByText("Automations · bb plugin")).toBeTruthy(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Source" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Built-in bb" })); + + expect(screen.getByText("bb-cli")).toBeTruthy(); + expect(screen.queryByText("library-skill")).toBeNull(); + expect(screen.queryByText("automations")).toBeNull(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Source" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Plugin-bundled" })); + + expect(screen.getByText("automations")).toBeTruthy(); + expect(screen.queryByText("bb-cli")).toBeNull(); }); it("renders browse content as the active full-page collection mode", () => { diff --git a/apps/server/src/services/plugins/manifest.ts b/apps/server/src/services/plugins/manifest.ts index 10fe1c420..130ee54dc 100644 --- a/apps/server/src/services/plugins/manifest.ts +++ b/apps/server/src/services/plugins/manifest.ts @@ -1,8 +1,10 @@ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; +import matter from "gray-matter"; import semver from "semver"; import { derivePluginId, pluginPackageJsonSchema } from "@bb/domain"; import { assertValidPluginCompactIconSvg } from "@bb/plugin-build"; +import { z } from "zod"; export interface PluginManifest { /** Sanitized plugin id derived from the package name. */ @@ -47,12 +49,11 @@ export interface PluginManifest { * empty array opts out. Missing directories resolve to no skills. */ skillsRootPaths: string[]; - /** - * Names of the skills found under `skillsRootPaths`, resolved once here so - * the frequently-called plugin list never does filesystem work. A skill is a - * directory containing SKILL.md, and its directory name is its name. - */ - skillNames: string[]; + /** Skills found under `skillsRootPaths`, including their user-facing purpose. */ + skills: Array<{ + name: string; + description: string | null; + }>; rootDir: string; } @@ -72,9 +73,17 @@ function resolveEntry(rootDir: string, entry: string, label: string): string { return resolved; } -/** Skill directory names under the given roots, sorted and de-duplicated. */ -async function readSkillNames(rootPaths: string[]): Promise { - const names = new Set(); +const skillCapabilityFrontmatterSchema = z + .object({ + description: z.string().trim().min(1), + }) + .passthrough(); + +/** Plugin skills under the given roots, sorted and de-duplicated. */ +async function readSkillSummaries( + rootPaths: string[], +): Promise { + const skills = new Map(); for (const rootPath of rootPaths) { let entries; try { @@ -88,15 +97,27 @@ async function readSkillNames(rootPaths: string[]): Promise { // lstat, not stat: a symlinked SKILL.md is rejected by the skill // loader, so counting it here would advertise a skill the agent never // loads. - const skillFile = await lstat(join(rootPath, entry.name, "SKILL.md")); + const skillPath = join(rootPath, entry.name, "SKILL.md"); + const skillFile = await lstat(skillPath); if (!skillFile.isFile()) continue; + const parsed = matter(await readFile(skillPath, "utf8")); + const frontmatter = skillCapabilityFrontmatterSchema.safeParse( + parsed.data, + ); + if (!skills.has(entry.name)) { + skills.set( + entry.name, + frontmatter.success ? frontmatter.data.description : null, + ); + } } catch { continue; } - names.add(entry.name); } } - return [...names].sort(); + return [...skills] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, description]) => ({ name, description })); } /** @@ -254,7 +275,7 @@ export async function readPluginManifest( appEntry: bb.app ? resolveEntry(rootDir, bb.app, "bb.app") : undefined, themes, skillsRootPaths, - skillNames: await readSkillNames(skillsRootPaths), + skills: await readSkillSummaries(skillsRootPaths), rootDir, }; } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index dd8df598d..7abfddf37 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1250,12 +1250,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { ): PluginCapabilitySummary { const capabilities: PluginCapabilitySummary = []; if (manifest !== undefined) { - for (const skillName of manifest.skillNames) { + for (const skill of manifest.skills) { capabilities.push({ kind: "skill", - id: skillName, - label: skillName, - detail: "Skill this plugin adds to your agents", + id: skill.name, + label: skill.name, + detail: skill.description ?? `Teach agents how to use ${skill.name}.`, }); } for (const theme of manifest.themes) { @@ -1280,7 +1280,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { kind: "thread-integration", id: `thread-action:${action.id}`, label: action.title, - detail: "Thread action", + detail: action.confirm ?? `Run ${action.title} from a thread.`, }); } for (const provider of exposedPlugin?.handle.mentionProviders ?? []) { @@ -1288,7 +1288,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { kind: "thread-integration", id: `mention:${provider.id}`, label: provider.label, - detail: `Mentions with ${provider.triggers.join(", ")}`, + detail: `Reference ${provider.label.toLowerCase()} in prompts with ${provider.triggers.join(", ")}.`, }); } return capabilities; diff --git a/apps/server/test/services/plugins/plugin-manifest.test.ts b/apps/server/test/services/plugins/plugin-manifest.test.ts index 80802d0b4..4840ea1c7 100644 --- a/apps/server/test/services/plugins/plugin-manifest.test.ts +++ b/apps/server/test/services/plugins/plugin-manifest.test.ts @@ -115,6 +115,22 @@ describe("plugin manifest", () => { expect(manifest.branding.compactIconPath).toBeUndefined(); }); + it("reads plugin skill descriptions from SKILL.md frontmatter", async () => { + await mkdir(join(rootDir, "skills", "review"), { recursive: true }); + await writeFile( + join(rootDir, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review code for correctness.\n---\n# Review\n", + ); + await writeManifest(); + + expect((await readPluginManifest(rootDir)).skills).toEqual([ + { + name: "review", + description: "Review code for correctness.", + }, + ]); + }); + it("requires path-shaped icons to use branding.experimental_icon", async () => { await writeManifest(undefined, { ...validBb, diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 6b45c6998..5185d711f 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -99,8 +99,14 @@ describe("plugin service", () => { await mkdir(join(rootDir, "skills", "review"), { recursive: true }); await mkdir(join(rootDir, "skills", "triage"), { recursive: true }); await mkdir(join(rootDir, "skills", "not-a-skill"), { recursive: true }); - await writeFile(join(rootDir, "skills", "review", "SKILL.md"), "# review"); - await writeFile(join(rootDir, "skills", "triage", "SKILL.md"), "# triage"); + await writeFile( + join(rootDir, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review code for correctness.\n---\n# review", + ); + await writeFile( + join(rootDir, "skills", "triage", "SKILL.md"), + "---\nname: triage\ndescription: Triage incoming issues.\n---\n# triage", + ); await writeFile(join(rootDir, "midnight.css"), ":root { --canvas: #000; }"); await writePlugin(workDir, { name: "bb-plugin-capabilities", @@ -140,13 +146,13 @@ describe("plugin service", () => { kind: "skill", id: "review", label: "review", - detail: "Skill this plugin adds to your agents", + detail: "Review code for correctness.", }, { kind: "skill", id: "triage", label: "triage", - detail: "Skill this plugin adds to your agents", + detail: "Triage incoming issues.", }, { kind: "theme", @@ -164,7 +170,7 @@ describe("plugin service", () => { kind: "thread-integration", id: "mention:issues", label: "Issues", - detail: "Mentions with #", + detail: "Reference issues in prompts with #.", }, ]); From fc2441fdbf71ac8b2dddc140e7f50f6828397073 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 28 Jul 2026 20:28:04 -0700 Subject: [PATCH 2/2] Fix Tools Hub capability regressions --- .../components/tools/PluginCapabilities.tsx | 5 ++-- .../views/ToolsView.plugin-detail.test.tsx | 30 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx index 098226df5..000890270 100644 --- a/apps/app/src/components/tools/PluginCapabilities.tsx +++ b/apps/app/src/components/tools/PluginCapabilities.tsx @@ -304,8 +304,9 @@ export function PluginIncludes({ key: "frontend-app", label: "Frontend app", detail: - plugin.description ?? - `Provides ${plugin.name ?? plugin.id} screens while its app is loaded.`, + plugin.description !== null + ? `Adds app screens to bb. ${plugin.description}` + : `Provides ${plugin.name ?? plugin.id} screens while its app is loaded.`, }); } diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index a8db78539..e7bbe8650 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -312,18 +312,38 @@ describe("PluginDetail capability inventory", () => { expect(includes).not.toBeNull(); const inventory = within(includes as HTMLElement); expect(inventory.getByText("Run monitor")).toBeTruthy(); - expect(inventory.getByText("Navigation panel")).toBeTruthy(); + expect(inventory.getByText("Open Run monitor in bb.")).toBeTruthy(); expect(inventory.getByText("enhance-prompt")).toBeTruthy(); - expect(inventory.getByText("Composer action")).toBeTruthy(); + expect( + inventory.getByText("Run enhance-prompt from the composer."), + ).toBeTruthy(); expect(inventory.getByText("Advanced preferences")).toBeTruthy(); - expect(inventory.getByText("Custom settings section")).toBeTruthy(); + expect( + inventory.getByText("Configure Advanced preferences in Settings."), + ).toBeTruthy(); expect(inventory.getByText("API token")).toBeTruthy(); - expect(inventory.getByText("apiToken")).toBeTruthy(); + expect(inventory.getByText("Configure api token.")).toBeTruthy(); expect(inventory.getByText("bb capability")).toBeTruthy(); expect(inventory.getByText("watch")).toBeTruthy(); expect(inventory.getByText("sync")).toBeTruthy(); + expect( + inventory.getAllByText( + "Runs in the background. Browse GitHub issues and pull requests in BB.", + ), + ).toHaveLength(2); expect(inventory.getByText("daily-cleanup")).toBeTruthy(); - expect(inventory.getByText("0 9 * * *")).toBeTruthy(); + expect( + inventory.getByText( + "Runs on 0 9 * * *. Browse GitHub issues and pull requests in BB.", + ), + ).toBeTruthy(); + for (const implementationLabel of [ + "Navigation panel", + "Composer action", + "Custom settings section", + ]) { + expect(inventory.queryByText(implementationLabel)).toBeNull(); + } expect(includes?.textContent).not.toContain("2 background services"); const groups = includes?.querySelectorAll("[data-plugin-capability-group]");