diff --git a/AGENTS.md b/AGENTS.md index 8bfc5fd17d..741bf7e772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ ## Plugin API - Any new public plugin API member (a `@get-bb/plugin-sdk/app` export, an `app.slots.*` method, or a `BbPluginApi` property) ships with an `experimental_` name prefix and an entry in [docs/api_to_audit.md](docs/api_to_audit.md) describing what it does and what to audit before stabilizing. Dropping the prefix is the deliberate stabilization step: audit the entry, rename project-wide, and remove it from the doc in the same change. +- The bb Plugin Guide (the `plugin-api-docs` plugin, rendering `packages/plugin-api-map`) is bb's only plugin API documentation. A new surface needs a card in `packages/plugin-api-map/src/surfaces.ts` naming its SDK symbols in the same change; `packages/plugin-api-map/test/api-sync.test.ts` fails the build when the map and the SDK drift apart. ## Data Access diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 722e1db661..bbda645a54 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -41,7 +41,6 @@ import { SETTINGS_SECTION_ROUTE_PATH, SKILLS_ROUTE_PATH, TOOLS_PLUGIN_BROWSE_ROUTE_PATH, - TOOLS_PLUGIN_DETAIL_ROUTE_PATH, TOOLS_PLUGINS_ROUTE_PATH, TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH, TOOLS_REGISTRY_SKILLS_ROUTE_PATH, @@ -319,10 +318,6 @@ function AppRoutes() { path={TOOLS_PLUGIN_BROWSE_ROUTE_PATH} element={} /> - } - /> } diff --git a/apps/app/src/components/plugin/PluginComposerBanners.test.tsx b/apps/app/src/components/plugin/PluginComposerBanners.test.tsx index 0c4ed7df82..b20f4666c1 100644 --- a/apps/app/src/components/plugin/PluginComposerBanners.test.tsx +++ b/apps/app/src/components/plugin/PluginComposerBanners.test.tsx @@ -232,4 +232,27 @@ describe("ComposerBannersSlot", () => { ); expect(view.container.textContent).toBe("Plugin rowBB row"); }); + + it("preserves BB-owned rows when plugin contributions are excluded", () => { + setPluginSlotRegistrations( + "excluded-plugin", + registrations([ + { + id: "excluded", + banners: [{ id: "plugin", component: () =>
Plugin row
}], + }, + ]), + ); + + const view = render( + +
BB row
+
, + ); + + expect(view.container.textContent).toBe("BB row"); + }); }); diff --git a/apps/app/src/components/plugin/PluginComposerBanners.tsx b/apps/app/src/components/plugin/PluginComposerBanners.tsx index cef1769237..07e40d82d9 100644 --- a/apps/app/src/components/plugin/PluginComposerBanners.tsx +++ b/apps/app/src/components/plugin/PluginComposerBanners.tsx @@ -14,18 +14,26 @@ export function ComposerBannersSlot({ view, children, ownerPlacement = "after", + includePluginContributions = true, }: { view?: ComposerView; children?: ReactNode; ownerPlacement?: "before" | "after"; + includePluginContributions?: boolean; }) { return view === undefined ? ( - + {children} ) : ( - + {children} @@ -35,15 +43,17 @@ export function ComposerBannersSlot({ function ComposerBannerRows({ children, ownerPlacement, + includePluginContributions, }: { children?: ReactNode; ownerPlacement: "before" | "after"; + includePluginContributions: boolean; }) { const view = useOptionalPluginComposerView(); const banners = useResolvedComposerBanners(view?.scope.kind ?? null); const scopeKey = view === undefined ? null : composerScopeIdentity(view.scope); - const pluginRows = banners.map( + const pluginRows = (includePluginContributions ? banners : []).map( ({ key, pluginId, customizationId, banner }) => { const slotId = `${customizationId}/${banner.id}`; return ( diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 9c79e421d4..7c5c95e774 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -437,6 +437,38 @@ describe("PluginNewThreadComposer seeding", () => { }); }); + it("maps the public customization policy to host composer isolation", async () => { + const composer = (policy?: "all" | "none") => ( + + undefined} + /> + + ); + const view = render(composer()); + + expect(latestPromptBoxProps().suppressPluginComposerCustomizations).toBe( + false, + ); + + view.rerender(composer("none")); + await waitFor(() => { + expect(latestPromptBoxProps().suppressPluginComposerCustomizations).toBe( + true, + ); + }); + + view.rerender(composer("all")); + await waitFor(() => { + expect(latestPromptBoxProps().suppressPluginComposerCustomizations).toBe( + false, + ); + }); + }); + it("binds plugin draft actions to the hosted composer instance", async () => { renderComposer(STORED_REQUEST, () => undefined, "host-binding"); diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.tsx index 9e6f0ffb17..7f41031dba 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.tsx @@ -29,6 +29,7 @@ export function PluginNewThreadComposer({ focusRequest, className, draftKey, + experimental_pluginCustomizations, onSubmit, }: PluginComposerProps) { const pluginId = useContext(PluginContext); @@ -79,6 +80,8 @@ export function PluginNewThreadComposer({ {renderPromptBox({ placeholder, allowNoProject: true, + suppressPluginComposerCustomizations: + experimental_pluginCustomizations === "none", })} )} diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx index 8934425814..94b5ba6868 100644 --- a/apps/app/src/components/plugin/PluginSlotMount.tsx +++ b/apps/app/src/components/plugin/PluginSlotMount.tsx @@ -1,5 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { Pill } from "@bb/shared-ui/pill"; +import { useRouteAnchorDelegate } from "@/components/ui/app-route-anchor"; import { usePluginCss } from "@/lib/plugin-css"; import { PluginContext, @@ -224,6 +225,7 @@ export function PluginSlotMount({ instanceId, onCrash, }: PluginSlotMountProps) { + const onRouteAnchorClick = useRouteAnchorDelegate(); usePluginCss(pluginId); return ( @@ -242,6 +244,12 @@ export function PluginSlotMount({ data-bb-plugin-root="" data-bb-plugin={pluginId} className="contents" + // Links in plugin UI behave like links anywhere else in bb: a plain + // click on an app route navigates client-side (a bare anchor would + // full-load the app and wipe its Back history), and cmd-click opens + // a splittable page beside the focused pane. Bubble phase, so the + // plugin's own handlers run first and can preventDefault. + onClick={onRouteAnchorClick} > {children} diff --git a/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx b/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx new file mode 100644 index 0000000000..4c5ac8dd9d --- /dev/null +++ b/apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx @@ -0,0 +1,253 @@ +// @vitest-environment jsdom +/** + * Guards the Plugin Guide UI-anatomy manifest against the real app. + * + * The Plugin Guide product-map skeletons (packages/plugin-api-map) render the + * sidebar sections, the sidebar footer, and the message action bar in the + * order declared by anatomy-manifest.json. This test renders the real + * components and asserts the same DOM order, so reordering the app fails here + * until the manifest — and therefore the guide — is updated. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Provider as JotaiProvider } from "jotai"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; + +import { makeProject } from "../../../.ladle/story-fixtures"; +import { AppCommandProvider } from "@/components/commands/AppCommandProvider"; +import { QuickCreateProjectProvider } from "@/hooks/useQuickCreateProject"; +import { ProjectActionsProvider } from "@/components/project/ProjectActionsProvider"; +import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; +import { AppSidebar } from "@/components/sidebar/AppSidebar"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { MessageActionBar } from "@/components/thread/timeline/MessageActionBar"; +import { + removePluginSlotRegistrations, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { sidebarNavigationQueryKey } from "@/hooks/queries/query-keys"; + +const manifest = JSON.parse( + readFileSync( + resolve( + import.meta.dirname, + "../../../../../packages/plugin-api-map/src/anatomy-manifest.json", + ), + "utf8", + ), +) as { + appSidebar: string[]; + sidebarFooter: string[]; + messageActionBar: string[]; +}; + +const TEST_PLUGIN_ID = "docs-anatomy-test"; + +beforeAll(() => { + // jsdom gaps the sidebar/tooltip stack expects. + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + window.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + Element.prototype.scrollIntoView ??= () => {}; +}); + +afterEach(() => { + removePluginSlotRegistrations(TEST_PLUGIN_ID); + cleanup(); +}); + +/** Asserts the elements appear in the given document order. */ +function expectDocumentOrder(labeled: Array<[string, Element]>): void { + for (let index = 0; index < labeled.length - 1; index += 1) { + const [beforeName, before] = labeled[index]; + const [afterName, after] = labeled[index + 1]; + const position = before.compareDocumentPosition(after); + expect( + (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, + `expected "${beforeName}" to render before "${afterName}"`, + ).toBe(true); + } +} + +function registerTestPlugin() { + setPluginSlotRegistrations(TEST_PLUGIN_ID, { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "anatomy-panel", + title: "Anatomy test panel", + icon: "Zap", + path: "anatomy", + component: () => null, + }, + ], + threadPanelActions: [], + sidebarFooterActions: [ + { + id: "anatomy-footer", + title: "Anatomy footer action", + icon: "Zap", + run: () => {}, + }, + ], + fileOpeners: [], + messageDirectives: [], + }); +} + +function renderAppSidebar() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + queryClient.setQueryData(sidebarNavigationQueryKey(), { + sections: [], + personalProject: { + ...makeProject({ + id: PERSONAL_PROJECT_ID, + kind: "personal", + name: "Personal", + }), + defaultExecutionOptions: null, + threads: [], + }, + projects: [], + }); + + return render( + + + + + + + + + + {}} + isResizing={false} + showTopReserve + settingsRoutePath="/settings" + toolsRoutePath="/tools" + /> + + + + + + + + + , + ); +} + +describe("docs anatomy manifest", () => { + it("matches AppSidebar's section order", () => { + registerTestPlugin(); + const { container } = renderAppSidebar(); + + const sectionSelectors: Record = { + "top-reserve": '[data-testid="app-sidebar-top-reserve-row"]', + "primary-actions": '[data-testid="app-sidebar-primary-actions"]', + "plugin-nav": '[data-testid="plugin-nav-sidebar-items"]', + "thread-list": '[data-sidebar="content"]', + footer: '[data-sidebar="footer"]', + }; + expect(Object.keys(sectionSelectors).sort()).toEqual( + [...manifest.appSidebar].sort(), + ); + + const sections = manifest.appSidebar.map((key): [string, Element] => { + const element = container.querySelector(sectionSelectors[key]); + expect(element, `missing sidebar section "${key}"`).not.toBeNull(); + return [key, element as Element]; + }); + expectDocumentOrder(sections); + }); + + it("matches the sidebar footer's item order", () => { + registerTestPlugin(); + const { container } = renderAppSidebar(); + const footer = container.querySelector('[data-sidebar="footer"]'); + expect(footer).not.toBeNull(); + + const footerSelectors: Record Element | null> = { + settings: () => footer!.querySelector('a[aria-label^="Settings"]'), + "plugin-footer-actions": () => + footer!.querySelector('button[aria-label="Anatomy footer action"]'), + "bug-report": () => footer!.querySelector('[aria-label^="Report a bug"]'), + }; + expect(Object.keys(footerSelectors).sort()).toEqual( + [...manifest.sidebarFooter].sort(), + ); + + const items = manifest.sidebarFooter.map((key): [string, Element] => { + const element = footerSelectors[key](); + expect(element, `missing footer item "${key}"`).not.toBeNull(); + return [key, element as Element]; + }); + expectDocumentOrder(items); + }); + + it("matches the message action bar's order", () => { + render( + + {}} + onEdit={() => {}} + onFork={() => {}} + onSendToMain={() => {}} + pluginActions={[ + { + key: "anatomy-plugin-action", + pluginId: null, + icon: null, + label: "Anatomy message action", + onSelect: () => {}, + }, + ]} + /> + , + ); + + const actionLabels: Record = { + copy: "Copy message", + edit: "Edit message", + "add-to-chat": "Add to chat", + "send-to-main-thread": "Send to main thread", + fork: "Fork into new thread", + "plugin-actions": "Anatomy message action", + }; + expect(Object.keys(actionLabels).sort()).toEqual( + [...manifest.messageActionBar].sort(), + ); + + const buttons = manifest.messageActionBar.map((key): [string, Element] => { + const element = screen.getByLabelText(actionLabels[key]); + return [key, element]; + }); + expectDocumentOrder(buttons); + }); +}); diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 80bb9b76cf..a66176c7aa 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -812,6 +812,9 @@ function FollowUpPromptBoxWithComposer({ active={composer.threadRuntimeDisplayStatus === "active"} composerElement={composerElement} hasPluginComposerScope={composerScope !== null} + includePluginComposerCustomizations={ + !suppressPluginComposerCustomizations + } isPrimaryComposer={isPrimaryComposer} pendingInteraction={pendingInteraction} showScrollToBottomButton={showScrollToBottomButton} @@ -827,6 +830,7 @@ interface DefaultFollowUpComposerProps { active: boolean; composerElement: ReactNode; hasPluginComposerScope: boolean; + includePluginComposerCustomizations: boolean; isPrimaryComposer: boolean; pendingInteraction?: ReactNode; showScrollToBottomButton: boolean; @@ -839,6 +843,7 @@ function DefaultFollowUpComposer({ active, composerElement, hasPluginComposerScope, + includePluginComposerCustomizations, isPrimaryComposer, pendingInteraction = null, showScrollToBottomButton, @@ -858,7 +863,11 @@ function DefaultFollowUpComposer({ >
{hasPluginComposerScope ? ( - {stack} + + {stack} + ) : ( stack )} diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 58b6005934..2adda593b1 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -122,6 +122,7 @@ interface NewThreadComposerPromptOptions { /** Override the host bound to this prompt box; omission uses this Composer's host. */ pluginComposerHost?: PluginComposerHost; textEffects?: NewThreadPromptBoxProps["textEffects"]; + suppressPluginComposerCustomizations?: boolean; allowNoProject?: boolean; createProject?: ProjectSelectorCreateProjectConfig; onRequestMachineSetup?: (host: Host) => void; @@ -1233,6 +1234,9 @@ export function NewThreadComposer({ autoFocus={options.autoFocus} pluginComposerHost={options.pluginComposerHost ?? pluginComposerHost} textEffects={options.textEffects ?? textEffects} + suppressPluginComposerCustomizations={ + options.suppressPluginComposerCustomizations + } history={{ currentDraft, entries: promptHistoryDrafts, diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index b8ece5a4c4..f737f20a16 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -182,6 +182,8 @@ interface NewThreadPromptBoxUIProps { /** Active root-composer binding for plugin composer hooks and customizations. */ pluginComposerHost?: PluginComposerHost | null; textEffects?: readonly ComposerTextEffectSource[]; + /** Hide every plugin composer customization; see PromptBoxInternal. */ + suppressPluginComposerCustomizations?: boolean; /** Overrides the default new-thread placeholder copy. */ placeholder?: string; @@ -234,6 +236,7 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ autoFocus, pluginComposerHost, textEffects, + suppressPluginComposerCustomizations, placeholder: placeholderOverride, history, typeahead, @@ -304,6 +307,9 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ disabledReason={disabledReason} autoFocus={autoFocus} textEffects={textEffects} + suppressPluginComposerCustomizations={ + suppressPluginComposerCustomizations + } placeholder={placeholderOverride} history={history} typeahead={typeahead} @@ -342,6 +348,7 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ disabledReason, autoFocus, textEffects, + suppressPluginComposerCustomizations, placeholder: placeholderOverride, history, typeahead, @@ -386,7 +393,10 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ className="w-full" >
- + {modeConfig.banner}
@@ -398,6 +408,9 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ onChange={onChange} onSubmit={onSubmit} textEffects={textEffects} + suppressPluginComposerCustomizations={ + suppressPluginComposerCustomizations + } onComposerLayoutChange={onComposerLayoutChange} history={history} typeahead={typeahead} diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts index 8ab975247d..5db79904a4 100644 --- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts +++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts @@ -6,8 +6,10 @@ import { getPluginPanelRoutePath, getRootComposeRoutePath, getThreadRoutePath, + getPluginDetailRoutePath, } from "@/lib/route-paths"; import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit"; import { countPanes, findPaneByContent, @@ -32,6 +34,9 @@ const MAIN_CONTENT_SELECTOR = "main"; function routeForContent(content: PaneContent): string { if (content.kind === "thread") return getThreadRoutePath(content); if (content.kind === "new-thread") return getRootComposeRoutePath(); + if (content.kind === "plugin-detail") { + return getPluginDetailRoutePath({ pluginId: content.pluginId }); + } return getPluginPanelRoutePath({ pluginId: content.pluginId, path: content.panelPath, @@ -54,21 +59,13 @@ export function usePaneContentSplitDrag({ const isCompact = useIsCompactViewport(); const openInSplit = useCallback(() => { - const route = routeForContent(content); - const layout = store.get(splitLayoutAtom); - if (!enabled || isCompact || layout === null) { - navigate(route); - return; - } - const existing = findPaneByContent(layout.root, content); - const next = - existing !== null - ? setFocus(layout, existing.paneId) - : countPanes(layout.root) >= MAX_PANES - ? replacePaneContent(layout, layout.focusedPaneId, content) - : splitPane(layout, layout.focusedPaneId, "right", content); - if (next !== layout) store.set(splitLayoutAtom, next); - navigate(route, existing !== null ? { replace: true } : undefined); + openPaneContentInSplit({ + store, + navigate, + content, + route: routeForContent(content), + enabled: enabled && !isCompact, + }); }, [content, enabled, isCompact, navigate, store]); const onPointerDown = useCallback( diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx index 341a02db2f..126db70661 100644 --- a/apps/app/src/components/ui/app-route-anchor.tsx +++ b/apps/app/src/components/ui/app-route-anchor.tsx @@ -11,8 +11,12 @@ import { type ReactNode, } from "react"; import { useNavigate, type NavigateOptions } from "react-router-dom"; +import { useStore } from "jotai"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { isRoutePath, resolveRouteHref } from "@/lib/route-paths"; import { getDesktopBrowserApi } from "@/lib/bb-desktop"; +import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit"; +import { paneContentForPathname } from "@/views/thread-detail/splitThreadNavigation"; interface RouteNavigationProviderProps { children: ReactNode; @@ -34,7 +38,17 @@ interface RouteNavigateOptions { /** Navigate to an absolute app route (`/projects/...`); see {@link useRouteNavigate}. */ type RouteNavigate = (path: string, options?: RouteNavigateOptions) => void; -const RouteNavigationContext = createContext(null); +interface RouteNavigation { + navigate: RouteNavigate; + /** + * Opens a route beside the focused pane, the way cmd-click on a sidebar + * row does. Returns false — and does nothing — when the route is not pane + * content or splits are off, so the caller can fall back to the browser. + */ + openInSplit: (path: string) => boolean; +} + +const RouteNavigationContext = createContext(null); /** * A `navigate` whose identity never changes and whose caller does not @@ -51,7 +65,9 @@ const RouteNavigationContext = createContext(null); * the click, not silently. */ export function useRouteNavigate(): RouteNavigate { - return useContext(RouteNavigationContext) ?? navigateWithoutProvider; + return ( + useContext(RouteNavigationContext)?.navigate ?? navigateWithoutProvider + ); } function navigateWithoutProvider(path: string): void { @@ -86,6 +102,8 @@ export function RouteNavigationProvider({ children, }: RouteNavigationProviderProps) { const navigate = useNavigate(); + const store = useStore(); + const isCompact = useIsCompactViewport(); // The live `navigate` changes per pathname; the context value must not, or // every consumer would re-render per navigation (the thing this exists to // avoid). Layout effect: the ref is current before any child effect or @@ -101,6 +119,21 @@ export function RouteNavigationProvider({ } navigateRef.current(path, options); }, []); + const openInSplit = useCallback( + (path) => { + const content = paneContentForPathname(path.split(/[?#]/)[0] ?? path); + if (content === null) return false; + openPaneContentInSplit({ + store, + navigate: navigateRoute, + content, + route: path, + enabled: !isCompact, + }); + return true; + }, + [isCompact, navigateRoute, store], + ); useEffect(() => { const browserApi = getDesktopBrowserApi(); if (browserApi === null) { @@ -114,13 +147,66 @@ export function RouteNavigationProvider({ }); }, [navigateRoute]); + const value = useMemo( + () => ({ navigate: navigateRoute, openInSplit }), + [navigateRoute, openInSplit], + ); return ( - + {children} ); } +/** + * A click handler for a container whose descendants may include anchors to + * app routes — plugin-rendered UI, chiefly. Plain clicks on such anchors + * navigate client-side, so the app's Back button keeps working; cmd/ctrl + * clicks open the route beside the focused pane when it can live in one. + * Links to a plugin's own page (its Extensions detail) open beside on any + * click: that page is a companion to whatever you are reading, and the + * Extensions list will open it the same way. Every other click, and every + * anchor to anywhere else, is left to the browser. Outside a + * RouteNavigationProvider it does nothing. + */ +export function useRouteAnchorDelegate(): ( + event: ReactMouseEvent, +) => void { + const navigation = useContext(RouteNavigationContext); + return useCallback( + (event) => { + if (navigation === null || event.defaultPrevented) return; + const anchor = + event.target instanceof Element + ? event.target.closest("a[href]") + : null; + if (anchor === null || !event.currentTarget.contains(anchor)) return; + const target = anchor.getAttribute("target"); + if (target !== null && target !== "" && target !== "_self") return; + if (event.button !== 0 || event.altKey || event.shiftKey) return; + const origin = currentOrigin(); + if (origin === null) return; + const route = resolveRouteHref({ + currentOrigin: origin, + href: anchor.getAttribute("href") ?? "", + }); + if (route === null) return; + const opensBeside = + event.metaKey || + event.ctrlKey || + paneContentForPathname(route.path.split(/[?#]/)[0] ?? route.path) + ?.kind === "plugin-detail"; + if (opensBeside) { + if (navigation.openInSplit(route.path)) event.preventDefault(); + return; + } + event.preventDefault(); + navigation.navigate(route.path); + }, + [navigation], + ); +} + export function RouteAnchor({ href, onClick, @@ -128,7 +214,7 @@ export function RouteAnchor({ target, ...anchorProps }: RouteAnchorProps) { - const navigateRoute = useContext(RouteNavigationContext); + const navigation = useContext(RouteNavigationContext); const route = useMemo(() => { const origin = currentOrigin(); return origin === null || href === undefined @@ -140,16 +226,16 @@ export function RouteAnchor({ onClick?.(event); if ( route === null || - navigateRoute === null || + navigation === null || !shouldHandleRouteAnchorClick({ event }) ) { return; } event.preventDefault(); - navigateRoute(route.path); + navigation.navigate(route.path); }, - [navigateRoute, onClick, route], + [navigation, onClick, route], ); return ( diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index 50ad80459f..312b15202b 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -174,6 +174,38 @@ describe("reconcilePluginFrontends", () => { ); }); + it("waits for the stylesheet before publishing registrations", async () => { + // Registrations are what mount a plugin's components. Publishing them + // while the stylesheet is still in flight paints one unstyled frame — + // the plugin's UI renders at its natural, oversized layout and then + // snaps down when the sheet lands. + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("hello", "aaa")]); + const cssGate: { release: (() => void) | null } = { release: null }; + vi.mocked(deps.applyCss).mockImplementation( + () => + new Promise((resolve) => { + cssGate.release = resolve; + }), + ); + + const done = reconcilePluginFrontends(state, deps); + // Let every await before the CSS gate settle. + for (let tick = 0; tick < 20; tick++) await Promise.resolve(); + expect(deps.applyCss).toHaveBeenCalledWith( + "hello", + "/api/v1/plugins/hello/assets/app.css?h=aaa", + ); + expect(deps.setRegistrations).not.toHaveBeenCalled(); + + cssGate.release?.(); + await done; + expect(deps.setRegistrations).toHaveBeenCalledWith( + "hello", + expect.anything(), + ); + }); + it("reloading twice leaves exactly one homepage section registered (design §9 exit criterion)", async () => { resetPluginSlotStoreForTest(); const state = createPluginFrontendReconcileState(); diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index a89736238e..416ccecff6 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -424,8 +424,12 @@ export function createPluginFrontendReconcileState(): PluginFrontendReconcileSta export interface PluginFrontendReconcileDeps { fetchCandidates: () => Promise; importModule: (url: string) => Promise; - /** Synchronously publish (string) or remove (null) the generation's CSS URL. */ - applyCss: (pluginId: string, url: string | null) => void; + /** + * Publish (string) or remove (null) the generation's CSS URL. Awaited before + * content scripts or registrations go live so injected implementations can + * preserve the same ordering as the synchronous production CSS manager. + */ + applyCss: (pluginId: string, url: string | null) => void | Promise; /** Retain the published CSS through one non-React consumer's lifetime. */ retainCss: (pluginId: string) => () => void; resetCrashedSlots: (pluginId: string) => void; @@ -849,7 +853,7 @@ async function reconcileCandidates( // Publish the URL before either non-React scripts mount or slot-store // notifications can render plugin code. Inactive plugins only preload; // an already-mounted generation starts a safe side-by-side replacement. - deps.applyCss(pluginId, candidate.bundle.cssUrl); + await deps.applyCss(pluginId, candidate.bundle.cssUrl); const cssRelease = collected.contentScripts.length > 0 ? deps.retainCss(pluginId) : null; const disposeFailures = await deactivateCommittedGeneration( diff --git a/apps/app/src/lib/split-layout/openPaneContentInSplit.ts b/apps/app/src/lib/split-layout/openPaneContentInSplit.ts new file mode 100644 index 0000000000..d7385cc148 --- /dev/null +++ b/apps/app/src/lib/split-layout/openPaneContentInSplit.ts @@ -0,0 +1,81 @@ +import { splitLayoutAtom } from "./atoms"; +import { + countPanes, + findPaneByContent, + MAX_PANES, + replacePaneContent, + setFocus, + splitPane, + type PaneContent, + type SplitLayout, +} from "./index"; + +interface SplitLayoutStore { + get(atom: typeof splitLayoutAtom): SplitLayout | null; + set(atom: typeof splitLayoutAtom, value: SplitLayout): void; +} + +export interface OpenPaneContentInSplitArgs { + store: SplitLayoutStore; + /** react-router's navigate, or anything with the same first two arguments. */ + navigate: ( + route: string, + options?: { replace?: boolean }, + ) => void | Promise; + content: PaneContent; + /** The URL this content owns, so the focused pane and the URL agree. */ + route: string; + /** Splits are off on compact viewports and outside the split workspace. */ + enabled: boolean; +} + +/** + * Open non-thread page content beside the focused pane: focus it if a pane + * already holds it, replace at the pane cap, otherwise split right. Falls + * back to plain navigation where there is no split to grow. + * + * Shared by the sidebar's cmd-click/drag entry point and by cmd-click on an + * app-route link inside plugin UI (useRouteAnchorDelegate), so both place + * the same page the same way. + */ +export function openPaneContentInSplit({ + store, + navigate, + content, + route, + enabled, +}: OpenPaneContentInSplitArgs): void { + const layout = store.get(splitLayoutAtom); + if (!enabled || layout === null) { + void navigate(route); + return; + } + const existing = findPaneByContent(layout.root, content); + const next = + existing !== null + ? setFocus(layout, existing.paneId) + : countPanes(layout.root) >= MAX_PANES + ? replacePaneContent(layout, layout.focusedPaneId, content) + : splitPane(layout, layout.focusedPaneId, "right", content); + if (next !== layout) store.set(splitLayoutAtom, next); + void navigate(route, existing !== null ? { replace: true } : undefined); +} + +/** + * Whether the workspace is already holding a plugin's detail page in a pane. + * + * The detail page is full-window like the rest of Extensions by default; it + * only renders as a pane when something deliberately put it there (cmd-click + * on a link to it from plugin UI). Deciding from the + * layout rather than the URL is what keeps ordinary navigation to the page + * from evicting whatever the focused pane was showing. + */ +export function holdsPluginDetailPane( + layout: SplitLayout | null, + pluginId: string, +): boolean { + if (layout === null) return false; + return ( + findPaneByContent(layout.root, { kind: "plugin-detail", pluginId }) !== null + ); +} diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts index d0492ffac7..4008701f83 100644 --- a/apps/app/src/lib/split-layout/ops.ts +++ b/apps/app/src/lib/split-layout/ops.ts @@ -80,6 +80,12 @@ export function findPaneByContent( candidate.threadId === content.threadId ); } + if (content.kind === "plugin-detail") { + return ( + candidate.kind === "plugin-detail" && + candidate.pluginId === content.pluginId + ); + } return ( candidate.kind === "plugin-panel" && candidate.pluginId === content.pluginId && diff --git a/apps/app/src/lib/split-layout/persistence.ts b/apps/app/src/lib/split-layout/persistence.ts index 841c23f9c8..8a03f700bf 100644 --- a/apps/app/src/lib/split-layout/persistence.ts +++ b/apps/app/src/lib/split-layout/persistence.ts @@ -22,6 +22,12 @@ const paneContentSchema = z.discriminatedUnion("kind", [ subPath: z.string(), }) .strict(), + z + .object({ + kind: z.literal("plugin-detail"), + pluginId: z.string().min(1), + }) + .strict(), ]); const paneNodeSchema: z.ZodType = z diff --git a/apps/app/src/lib/split-layout/types.ts b/apps/app/src/lib/split-layout/types.ts index 36923f5f14..f65745f6ec 100644 --- a/apps/app/src/lib/split-layout/types.ts +++ b/apps/app/src/lib/split-layout/types.ts @@ -12,6 +12,11 @@ export type PaneContent = pluginId: string; panelPath: string; subPath: string; + } + /** An installed plugin's Extensions detail page. */ + | { + kind: "plugin-detail"; + pluginId: string; }; export interface PaneNode { diff --git a/apps/app/src/views/SplitWorkspaceRoute.tsx b/apps/app/src/views/SplitWorkspaceRoute.tsx index d08e40b9db..a8a6b57095 100644 --- a/apps/app/src/views/SplitWorkspaceRoute.tsx +++ b/apps/app/src/views/SplitWorkspaceRoute.tsx @@ -1,5 +1,8 @@ -import { useMemo } from "react"; +import { lazy, useMemo } from "react"; import { matchPath, Navigate, useLocation } from "react-router-dom"; +import { useAtomValue } from "jotai"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { holdsPluginDetailPane } from "@/lib/split-layout/openPaneContentInSplit"; // Route views render icons outside the shell's core set. Importing the // extended registry here ships it as a static dependency of this route chunk, // so those icons never flash blank waiting for an on-demand load. @@ -8,6 +11,7 @@ import { APP_ROOT_ROUTE_PATH, LEGACY_PROJECT_COMPOSE_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, + TOOLS_PLUGIN_DETAIL_ROUTE_PATH, } from "@/lib/route-paths"; import type { PaneContent } from "@/lib/split-layout"; import { useRouteState } from "@/hooks/useRouteState"; @@ -16,6 +20,12 @@ import { SplitThreadArea } from "./thread-detail/SplitThreadArea"; const ROOT_COMPOSE_CONTENT = { kind: "new-thread" } as const; +// The Extensions detail page, for the full-window case below. Lazy, like the +// other Extensions routes in App.tsx, so it stays out of the workspace chunk. +const ToolsView = lazy(() => + import("./ToolsView").then((m) => ({ default: m.ToolsView })), +); + /** * Stable route owner for every page that can live in the split workspace. * @@ -27,6 +37,10 @@ export default function SplitWorkspaceRoute() { const location = useLocation(); const { projectId, threadId, isThreadView } = useRouteState(); const pluginMatch = matchPath(PLUGIN_PANEL_ROUTE_PATH, location.pathname); + const pluginDetailMatch = matchPath( + TOOLS_PLUGIN_DETAIL_ROUTE_PATH, + location.pathname, + ); const legacyProjectMatch = matchPath( LEGACY_PROJECT_COMPOSE_ROUTE_PATH, location.pathname, @@ -34,6 +48,7 @@ export default function SplitWorkspaceRoute() { const pluginId = pluginMatch?.params.pluginId; const panelPath = pluginMatch?.params.panelPath; const pluginSubPath = pluginMatch?.params["*"] ?? ""; + const detailPluginId = pluginDetailMatch?.params.pluginId; const routeContent = useMemo(() => { if (location.pathname === APP_ROOT_ROUTE_PATH) { @@ -42,6 +57,9 @@ export default function SplitWorkspaceRoute() { if (isThreadView && projectId && threadId) { return { kind: "thread", projectId, threadId }; } + if (detailPluginId) { + return { kind: "plugin-detail", pluginId: detailPluginId }; + } if (pluginId && panelPath) { return { kind: "plugin-panel", @@ -52,6 +70,7 @@ export default function SplitWorkspaceRoute() { } return null; }, [ + detailPluginId, isThreadView, location.pathname, panelPath, @@ -61,6 +80,8 @@ export default function SplitWorkspaceRoute() { threadId, ]); + const layout = useAtomValue(splitLayoutAtom); + const legacyProjectId = legacyProjectMatch?.params.projectId; if (legacyProjectId) { return ; @@ -68,5 +89,18 @@ export default function SplitWorkspaceRoute() { if (routeContent === null) { return ; } + // A plugin's detail page is full-window, like the rest of Extensions, + // unless the workspace already holds it in a split pane — which only + // happens when something deliberately opened it there (cmd-click on a link + // to it from plugin UI). The decision is made here rather than with a + // separate : this element must own every URL a pane can have, or + // focusing a different pane (which rewrites the URL) would swap Route + // elements and remount the whole workspace, threads included. + if ( + routeContent.kind === "plugin-detail" && + !holdsPluginDetailPane(layout, routeContent.pluginId) + ) { + return ; + } return ; } diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index 65936e8af6..3ba4b32fa9 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -397,6 +397,25 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) { ); } +/** + * The Extensions detail page, rendered from an explicit plugin id. + * + * A split pane cannot read the id from `useParams`: only the focused pane + * owns the URL, so an unfocused plugin-detail pane would otherwise show + * whatever plugin the focused pane happens to name. + */ +export function PluginDetailPaneView({ pluginId }: { pluginId: string }) { + return ( +
+
+ }> + + +
+
+ ); +} + export function ToolsView() { const location = useLocation(); const { pluginId } = useParams<{ diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index 8d0c53b721..760ac1bfb0 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -128,6 +128,20 @@ const LazyPluginPanelRightPanelHost = lazy(() => ), ); +const LazyPluginDetailPaneView = lazy(() => + import("@/views/ToolsView").then(({ PluginDetailPaneView }) => ({ + default: PluginDetailPaneView, + })), +); + +function PluginDetailPaneView({ pluginId }: { pluginId: string }) { + return ( + + + + ); +} + function PluginPagePanelHost({ children, ...props @@ -1037,6 +1051,9 @@ function StandalonePaneContent({ if (content.kind === "new-thread") { return ; } + if (content.kind === "plugin-detail") { + return ; + } const panelEntry = navPanelChrome.find( (candidate) => candidate.chrome.pluginId === content.pluginId && @@ -1126,7 +1143,9 @@ function NonThreadPaneContent({ isFocused ? resourceRouteLabel : null, ) : null; - const label = panelChrome?.title ?? "New thread"; + const label = + panelChrome?.title ?? + (content.kind === "plugin-detail" ? "Extension" : "New thread"); const handlePointerDown = (event: ReactPointerEvent) => { if ( event.target instanceof Element && @@ -1231,7 +1250,9 @@ function NonThreadPaneContent({ CONTEXT_INACTIVE_TEXT_CLASS, )} > - New thread + {content.kind === "plugin-detail" + ? "Extension" + : "New thread"}

)}
@@ -1250,6 +1271,8 @@ function NonThreadPaneContent({ > {content.kind === "new-thread" ? ( + ) : content.kind === "plugin-detail" ? ( + ) : ( =0.39", + "bbPluginSdk": ">=0.4.14" + }, + "bb": { + "name": "Sdk upgrade fixture", + "description": "A BB plugin.", + "branding": { + "icon": "Zap" + }, + "server": "./server.ts" + }, + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "0.4.14", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "better-sqlite3": "^12.0.0", + "hono": "^4.11.9", + "typescript": "^5.7.0" + } +} diff --git a/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.14-scaffold/server.ts b/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.14-scaffold/server.ts new file mode 100644 index 0000000000..af69f8b45a --- /dev/null +++ b/apps/server/test/fixtures/plugins/bb-plugin-sdk-0.4.14-scaffold/server.ts @@ -0,0 +1,5 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; + +export default function plugin(bb: BbPluginApi) { + bb.log.info("0.4.14 scaffold upgrade fixture loaded"); +} diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 24437ba1c1..373af5fcb7 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -217,6 +217,7 @@ describe("builtin plugin reconciliation", () => { ["keep-awake", "Coffee"], ["pdf-preview", "FileText"], ["provider-acp", "./icons/acp.svg"], + ["plugin-api-docs", "./icons/ai-generative.svg"], ["provider-claude-code", "./icons/claude-code.svg"], ["provider-codex", "./icons/codex.svg"], ["provider-pi", "./icons/pi.svg"], diff --git a/apps/server/test/services/plugins/official-plugins.test.ts b/apps/server/test/services/plugins/official-plugins.test.ts index 13519ec737..ff030e6f67 100644 --- a/apps/server/test/services/plugins/official-plugins.test.ts +++ b/apps/server/test/services/plugins/official-plugins.test.ts @@ -99,6 +99,7 @@ describe("official plugin registry invariants", () => { "keep-awake": "Host access", memory: "Context & knowledge", "pdf-preview": "Interface", + "plugin-api-docs": "Developer tools", "provider-acp": "Agent interaction", "provider-claude-code": "Agent interaction", "provider-codex": "Agent interaction", diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index c01a8b7ab4..8a6e95cc75 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -1,6 +1,8 @@ import { + cp, mkdtemp, mkdir, + readFile, rename, rm, symlink, @@ -16,7 +18,7 @@ import { upsertInstalledPlugin, type DbConnection, } from "@bb/db"; -import type { SystemChangeKind } from "@bb/domain"; +import { PLUGIN_SDK_VERSION, type SystemChangeKind } from "@bb/domain"; import type { Logger } from "@bb/logger"; import { createPluginService, @@ -644,6 +646,73 @@ describe("plugin service", () => { await after.stop(); }); + it("keeps a persisted 0.4.14 scaffold plugin running after the 0.4.15 SDK upgrade", async () => { + // This package.json is frozen from `bb plugin new sdk-upgrade-fixture` + // shipped by bb 0.39.0 with @get-bb/plugin-sdk 0.4.14. Copy it into a + // user-owned path, then persist the registration before starting the + // current host so this exercises a real upgrade rather than a fresh + // current-version install. + const fixtureDir = new URL( + "../../fixtures/plugins/bb-plugin-sdk-0.4.14-scaffold/", + import.meta.url, + ); + const rootDir = join(workDir, "bb-plugin-sdk-upgrade-fixture"); + await cp(fixtureDir, rootDir, { recursive: true }); + const manifest = JSON.parse( + await readFile(join(rootDir, "package.json"), "utf8"), + ) as { + engines: { bbPluginSdk: string }; + devDependencies: Record; + }; + expect(manifest.engines.bbPluginSdk).toBe(">=0.4.14"); + expect(manifest.devDependencies["@get-bb/plugin-sdk"]).toBe("0.4.14"); + expect(PLUGIN_SDK_VERSION).toBe("0.4.15"); + + upsertInstalledPlugin(db, { + id: "sdk-upgrade-fixture", + source: `path:${rootDir}`, + provenance: { kind: "direct" }, + sourceIntent: { kind: "path", canonicalPath: rootDir }, + exactResolution: { kind: "path" }, + updateState: { + lastCheckAt: null, + availableCompatibleVersion: null, + newestIncompatibleVersion: null, + statusDetail: null, + }, + activeArtifactId: null, + rootDir, + version: "0.1.0", + enabled: true, + }); + + const upgraded = createPluginService({ + telemetry: createNoopTelemetryService(), + db, + hub: { + getDaemonSessionIdForHost: () => null, + notifyPluginSignal: () => 0, + notifySystem: () => {}, + }, + logger, + dataDir: join(workDir, "data"), + appVersion: "0.39.0", + loadTimeoutMs: 2000, + bundledPlugins: [], + }); + await upgraded.start(); + try { + const entry = upgraded + .list() + .find((plugin) => plugin.id === "sdk-upgrade-fixture"); + expect(entry?.status).toBe("running"); + expect(entry?.statusDetail).toBeNull(); + expect(upgraded.getApi("sdk-upgrade-fixture")).toBeDefined(); + } finally { + await upgraded.stop(); + } + }); + it("skips the engines gate on 0.0.0 dev builds instead of marking everything incompatible", async () => { const devService = createPluginService({ telemetry: createNoopTelemetryService(), diff --git a/apps/web/package.json b/apps/web/package.json index 091fc91855..68bc37d16e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@bb/connect-db": "workspace:*", + "@bb/fuzzy-match": "workspace:*", "@better-auth/drizzle-adapter": "^1.6.23", "@fontsource-variable/inter": "^5.2.8", "@hugeicons/core-free-icons": "^4.1.3", @@ -28,6 +29,7 @@ "posthog-js": "^1.386.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "sugar-high": "^1.2.1", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/apps/web/scripts/generate-plugin-api-docs.d.mts b/apps/web/scripts/generate-plugin-api-docs.d.mts new file mode 100644 index 0000000000..fee84b658c --- /dev/null +++ b/apps/web/scripts/generate-plugin-api-docs.d.mts @@ -0,0 +1,4 @@ +import type { PluginApiDocsModel } from "../src/docs-plugin-api/model"; + +export declare function generatePluginApiDocsModel(): PluginApiDocsModel; +export declare function renderGeneratedModule(): string; diff --git a/apps/web/src/blog/lightbox.tsx b/apps/web/src/blog/lightbox.tsx index db219dce0f..2ff9d8ca9f 100644 --- a/apps/web/src/blog/lightbox.tsx +++ b/apps/web/src/blog/lightbox.tsx @@ -77,7 +77,9 @@ export function LightboxImage({ + ); + }); +} diff --git a/packages/plugin-api-map/src/cn.ts b/packages/plugin-api-map/src/cn.ts new file mode 100644 index 0000000000..44d7c51475 --- /dev/null +++ b/packages/plugin-api-map/src/cn.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Local copy of the app's class merger. This package renders bb theme classes + * but must stay importable from a plugin bundle, which cannot reach into an + * app's `@/lib` alias. + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/plugin-api-map/src/index.ts b/packages/plugin-api-map/src/index.ts new file mode 100644 index 0000000000..2a8c8789b3 --- /dev/null +++ b/packages/plugin-api-map/src/index.ts @@ -0,0 +1,43 @@ +export { cn } from "./cn"; +export { + annotationChipClass, + ExperimentalBadge, + renderSurfaceCopy, + type SurfaceReference, +} from "./annotation"; +export { firstPartyPluginId, pluginIcon } from "./plugin-icons"; +export { SurfaceCard, useSurfaceCard } from "./surface-card"; +export { panCarets, ProductMap, SURFACE_NUMBERS } from "./product-map"; +export { + scrollUsedBy, + UsedByList, + usedByScrollState, + usedByScrollStep, + type UsedByScrollMetrics, + type UsedByScrollState, + type UsedByScrollTarget, +} from "./used-by"; +export { + GROUP_BY_SURFACE_ID, + SURFACE_GROUPS, + SURFACES_BY_ID, + type PluginSurface, + type SurfaceGroup, +} from "./surfaces"; +export { + AppShellWireframe, + ComposerWireframe, + ComposeScreenWireframe, + ExtensionsPluginPageWireframe, + SettingsWireframe, + SurfaceMapContext, + useSurfaceMap, + ANATOMY_RENDERER_KEYS, + APP_SHELL_MARKS, + COMPOSER_MARKS, + COMPOSE_MARKS, + EXTENSIONS_MARKS, + SETTINGS_MARKS, + type SurfaceMapState, +} from "./wireframes"; +export { default as ANATOMY_MANIFEST } from "./anatomy-manifest.json"; diff --git a/packages/plugin-api-map/src/plugin-icons.ts b/packages/plugin-api-map/src/plugin-icons.ts new file mode 100644 index 0000000000..fcf8e63b6f --- /dev/null +++ b/packages/plugin-api-map/src/plugin-icons.ts @@ -0,0 +1,111 @@ +/** + * The map's icons: the shipped bb plugins named in each surface's "Used by" + * list, and the capability glyph each pixel-less surface is drawn with. + * + * Both come from the plugin's own package.json — `bb.branding.icon` resolved + * through the same hugeicons set the app's icon registry uses, and the plugin + * id that `/extensions/plugins/` routes to. Provider plugins brand with + * bundled SVG files the docs cannot import, so they share one provider glyph. + */ +import { + ArrowDataTransferHorizontalIcon, + ArrowReloadHorizontalIcon, + BrainIcon, + BrowserIcon, + CheckListIcon, + Clock01Icon, + Coffee01Icon, + ComputerIcon, + DatabaseIcon, + Edit04Icon, + File01Icon, + GithubIcon, + Layers01Icon, + LockIcon, + MessageAdd02Icon, + MessageQuestionIcon, + SmartPhone01Icon, + SourceCodeIcon, + SparklesIcon, + TerminalIcon, + TestTubeIcon, + WorkflowCircle03Icon, + Activity03Icon, +} from "@hugeicons/core-free-icons"; +import type { IconSvgElement } from "@hugeicons/react"; + +interface FirstPartyPlugin { + /** Installed plugin id; the last segment of its page URL. */ + id: string; + icon: IconSvgElement; +} + +/** Keyed by the display name surfaces.ts lists in `firstParty`. */ +const FIRST_PARTY_PLUGINS: Record = { + "Ask User Question": { id: "ask-user-question", icon: MessageQuestionIcon }, + Automations: { id: "automations", icon: Clock01Icon }, + "Custom instructions": { id: "custom-instructions", icon: Edit04Icon }, + // Installed as `simple-notes` (its manifest source is `builtin:docs`), so + // the id and the builtin slug differ; the page URL uses the id. + Docs: { id: "simple-notes", icon: File01Icon }, + GitHub: { id: "github", icon: GithubIcon }, + "Inline visualizations": { id: "inline-vis", icon: BrowserIcon }, + "Keep Awake": { id: "keep-awake", icon: Coffee01Icon }, + Memory: { id: "memory", icon: BrainIcon }, + "Provider retry": { id: "provider-retry", icon: ArrowReloadHorizontalIcon }, + "Remote access": { id: "connect", icon: SmartPhone01Icon }, + Secrets: { id: "secrets", icon: LockIcon }, + "Side chat": { id: "side-chat", icon: MessageAdd02Icon }, + Tasks: { id: "tasks", icon: CheckListIcon }, + Workflows: { id: "workflows", icon: WorkflowCircle03Icon }, + "ACP providers": { id: "provider-acp", icon: SparklesIcon }, + "Claude Code provider": { id: "provider-claude-code", icon: SparklesIcon }, + "Codex provider": { id: "provider-codex", icon: SparklesIcon }, + "Pi provider": { id: "provider-pi", icon: SparklesIcon }, +}; + +export function pluginIcon(displayName: string): IconSvgElement | null { + return FIRST_PARTY_PLUGINS[displayName]?.icon ?? null; +} + +/** + * The installed-plugin id bb knows this plugin by, or null when the name is + * not one of the shipped plugins. + * + * Deliberately NOT turned into a URL here. A plugin only has a page when the + * running bb actually knows it (installed, or present in that host's + * catalog), so whether to link is a question only the host can answer; see + * `pluginPageHref` on ProductMap. Matching is by id rather than display name + * because a plugin's display name is not its id: bb's own Docs plugin is + * installed as `simple-notes`, and two catalog entries can share a name. + */ +export function firstPartyPluginId(displayName: string): string | null { + return FIRST_PARTY_PLUGINS[displayName]?.id ?? null; +} + +/** + * The capability glyph for a pixel-less surface, or null for one a skeleton + * draws (those are identified by their numbered marker instead). + * + * One definition, two readers: the capability card on the "Plugin backend" + * slide and the detail card that card opens. + */ +const SURFACE_ICONS: Record = { + cli: TerminalIcon, + "agent-tools": SparklesIcon, + background: Clock01Icon, + // Two opposing arrows, not the "{api}" glyph: that one is dense text + // in a box and unreadable at card size on a large monitor. + wire: ArrowDataTransferHorizontalIcon, + storage: DatabaseIcon, + // An activity line, not a bolt: the bolt is the app's skills glyph. + "thread-events": Activity03Icon, + "host-workers": ComputerIcon, + "bb-sdk": SourceCodeIcon, + "host-components": Layers01Icon, + testing: TestTubeIcon, +}; + +export function surfaceIcon(surfaceId: string): IconSvgElement | null { + return SURFACE_ICONS[surfaceId] ?? null; +} diff --git a/packages/plugin-api-map/src/product-map.tsx b/packages/plugin-api-map/src/product-map.tsx new file mode 100644 index 0000000000..b6d6f738f6 --- /dev/null +++ b/packages/plugin-api-map/src/product-map.tsx @@ -0,0 +1,639 @@ +/** + * The whole product map: one annotated skeleton of the bb UI at a time, panned + * through with the arrows, with a click on any numbered annotation opening its + * card in the nearest gutter (or directly below the diagram when no gutter + * fits). + * + * Slides are the surface groups, in order, so the data file decides both what + * a slide contains and what number each marker gets. The last group has no + * pixels to point at, so it renders as a conventional docs capability grid: + * named sections of icon + title + description cards. + * + * Rendered identically by the docs site and by the bb plugin; inside bb the + * plugin hands in the host's real composer twice — live on the Home slide, + * and inert on the composer slide, where it is annotated in place with both + * menus drawn open as static popovers. The docs site has no host, so both + * slides fall back to drawn mocks there. + */ +import { + Fragment, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; + +import { cn } from "./cn"; +import { SurfaceCard, useSurfaceCard } from "./surface-card"; +import { surfaceIcon } from "./plugin-icons"; +import { + GROUP_BY_SURFACE_ID, + SURFACE_GROUPS, + SURFACES_BY_ID, + type PluginSurface, + type SurfaceGroup, +} from "./surfaces"; +import { ExperimentalBadge, renderSurfaceCopy } from "./annotation"; +import { + AppShellWireframe, + ComposerWireframe, + ComposeScreenWireframe, + ExtensionsPluginPageWireframe, + RealComposerAnnotated, + SettingsWireframe, + SurfaceMapContext, + useSurfaceMap, +} from "./wireframes"; + +/** + * Marker numbers restart per slide, matching each skeleton's own markers. + * "Plugin backend" is absent on purpose: it has no skeleton, so a number there + * would point at nothing. + */ +export const SURFACE_NUMBERS: ReadonlyMap = new Map( + SURFACE_GROUPS.filter((group) => group.id !== "headless").flatMap((group) => + group.surfaces.map((surface, index) => [surface.id, index + 1] as const), + ), +); + +/** + * One capability row in the platform grid: icon, title, one-line tagline. + * The prose lives in the detail card a click opens, so the grid stays + * scannable. Same anchor as a skeleton marker, same measurement path. + */ +function PlatformCard({ surface }: { surface: PluginSurface }) { + const { activeId, setActiveId, expandedId, onSelect } = useSurfaceMap(); + const selected = activeId === surface.id || expandedId === surface.id; + const icon = surfaceIcon(surface.id); + return ( + { + event.preventDefault(); + onSelect(surface.id); + } + : undefined + } + onMouseEnter={() => setActiveId(surface.id)} + onMouseLeave={() => setActiveId(null)} + className={cn( + "flex h-full items-center gap-3 rounded-lg border px-4 py-4 transition-colors", + selected + ? "border-border bg-surface-selected" + : // Resting fill one step below hover: a faint opaque lift off the + // canvas, so idle cards read as cards, and the hover tint still + // lands a clear step darker. + "border-border-hairline bg-surface-raised-solid hover:border-border hover:bg-state-hover", + )} + > + {icon ? ( + + ) : null} + + + + {surface.title} + + {surface.experimental ? : null} + + + {renderSurfaceCopy(surface.tagline ?? surface.summary)} + + + + ); +} + +/** + * The pixel-less slide: small section eyebrows chunking a two-column grid + * of uniform one-line rows, so the ten capabilities scan in one pass. + */ +function PlatformSlide({ group }: { group: SurfaceGroup }) { + return ( +
+ {(group.sections ?? []).map((section) => { + const surfaces = section.surfaceIds + .map((id) => SURFACES_BY_ID.get(id)) + .filter((surface): surface is PluginSurface => Boolean(surface)); + return ( +
+

+ {section.title} +

+
    + {surfaces.map((surface) => ( +
  • + +
  • + ))} +
+
+ ); + })} +
+ ); +} + +/** + * The width the skeletons are drawn at. Below it they scale down as a whole + * rather than reflowing: the diagrams teach proportion — where a region sits + * relative to the rest of the window — and letting fixed-width regions + * collapse independently would draw a bb that does not exist. + */ +/** Matches the stage's `duration-300` pan, so a followed reference opens + * its card only once the target slide has actually arrived. */ +const SLIDE_PAN_MS = 300; + +const DIAGRAM_WIDTH = 900; + +/** + * Scales a skeleton to fit its column when the column is narrower than the + * width it is drawn at, so a diagram in a half-width split pane shrinks + * instead of running off the edge. + */ +function FitDiagram({ children }: { children: ReactNode }) { + const hostRef = useRef(null); + const contentRef = useRef(null); + const [scale, setScale] = useState(1); + const [height, setHeight] = useState(null); + + useEffect(() => { + const host = hostRef.current; + const content = contentRef.current; + if (!host || !content) return; + const measure = () => { + const available = host.clientWidth; + if (available === 0) return; + const next = Math.min(1, available / DIAGRAM_WIDTH); + setScale(next); + // The transform does not affect layout, so the host has to carry the + // scaled height itself or it would reserve the full untransformed one. + setHeight(next === 1 ? null : Math.round(content.offsetHeight * next)); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(host); + observer.observe(content); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ {children} +
+
+ ); +} + +function Slide({ + group, + realComposer, + annotatedComposer, +}: { + group: SurfaceGroup; + realComposer?: ReactNode; + /** + * A second instance of the host composer for the composer-anatomy slide, + * with its own draft key so edits on the Home slide never move the + * annotated diagram. Rendered inert; see RealComposerAnnotated. + */ + annotatedComposer?: ReactNode; + /** + * Resolves a shipped plugin's page in the running bb, or null when this + * host has no page for it. Only the in-app copy can answer that, so the + * docs website omits it and the "Used by" names render as plain text. + */ + pluginPageHref?: (displayName: string) => string | null; +}) { + switch (group.id) { + case "app-shell": + return ( + + + + ); + case "composer": + // Inside bb, the diagram is the real host composer rendered inert — + // authentic proportions, no interactivity, with both menus drawn as + // static popovers. The docs site has no host, so it keeps the mock. + // Not scaled: the live composer is a real interactive component, and a + // CSS transform would blur its text and offset its menus. + return annotatedComposer ? ( + + ) : ( + + + + ); + case "home": + return ; + case "settings": + return ( + + + + ); + case "extensions": + return ( + + + + ); + // The capability grid reflows on its own; scaling it would only shrink + // text that has room to wrap instead. + case "headless": + return ; + } +} + +/** + * A slide title with the bare word "bb" set the way the wordmark reads: + * bold italic. Text rather than the SVG mark on purpose — at heading size on + * a 1x display an 11px vector path rasterises to a blob, while the font + * rasteriser hints glyphs at any size. The title stays a plain string + * everywhere else — nav labels, aria, tests — so only the rendered heading + * changes. + */ +function SlideTitle({ title }: { title: string }) { + const parts = title.split(/\bbb\b/); + if (parts.length === 1) { + return <>{title}; + } + return ( + <> + {parts.map((part, index) => ( + + {index > 0 ? bb : null} + {part} + + ))} + + ); +} + +/** + * Which pan caret is enabled at `index`. Both carets always render so the + * row's geometry never changes; an end of the range just disables its caret. + * + * Pure so the ends are testable without a layout engine. + */ +export function panCarets( + index: number, + slideCount: number, +): { previous: boolean; next: boolean } { + return { previous: index > 0, next: index < slideCount - 1 }; +} + +function PanButton({ + direction, + disabled, + onClick, +}: { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** + * Keeps the stage exactly as tall as the slide on show, so a short skeleton + * does not leave the tallest one's empty space below it. + */ +function useStageHeight( + index: number, + slideRefs: React.RefObject>, +): number | null { + const [height, setHeight] = useState(null); + useEffect(() => { + const slide = slideRefs.current[index]; + if (!slide) { + return; + } + const measure = () => setHeight(slide.getBoundingClientRect().height); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(slide); + return () => observer.disconnect(); + }, [index, slideRefs]); + return height; +} + +export function ProductMap({ + header, + realComposer, + annotatedComposer, + pluginPageHref, + initialSlideId, + onSlideChange, + tone = "primary", +}: { + /** Page copy above the diagrams; omitted inside compact plugin panels. */ + header?: ReactNode; + /** + * The host's real composer (experimental_NewThreadComposer), supplied by + * the bb plugin. It replaces the mock composer in the skeletons, so the + * diagram is the actual product. Surfaces with no bb behind them omit it + * and get the mock. + */ + realComposer?: ReactNode; + /** + * A second host-composer instance for the composer-anatomy slide, seeded + * with the demo draft under its own draft key so edits on the Home slide + * never move the annotated diagram. Rendered inert. Omitted on the docs + * site, which falls back to the drawn mock. + */ + annotatedComposer?: ReactNode; + /** + * Resolves a shipped plugin's page in the running bb, or null when this + * host has no page for it. Only the in-app copy can answer that, so the + * docs website omits it and the "Used by" names render as plain text. + */ + pluginPageHref?: (displayName: string) => string | null; + /** + * The slide to open on, by surface-group id. The bb plugin feeds the nav + * panel's subPath back in here, so leaving the page and coming back (the + * app's Back button, a shared link) lands on the slide you left. + */ + initialSlideId?: string; + /** Fires when the reader pans; the bb plugin mirrors it into the URL. */ + onSlideChange?: (slideId: string) => void; + /** + * "supporting" steps the per-slide heading and blurb down a level, for + * pages where the map explains the docs rather than leading them. Behavior, + * markers, and card content are identical either way. + */ + tone?: "primary" | "supporting"; +}) { + const slides = SURFACE_GROUPS; + const containerRef = useRef(null); + const slideRefs = useRef>([]); + const card = useSurfaceCard(); + const [hoverId, setHoverId] = useState(null); + const [index, setIndex] = useState(() => + Math.max( + 0, + slides.findIndex((slide) => slide.id === initialSlideId), + ), + ); + const stageHeight = useStageHeight(index, slideRefs); + + const openSurface = card.openId ? SURFACES_BY_ID.get(card.openId) : undefined; + const carets = panCarets(index, slides.length); + + // Panning away from a card's marker would strand the card, so it closes. + const show = (next: number) => { + if (next < 0 || next >= slides.length) { + return; + } + card.close(); + setHoverId(null); + setIndex(next); + onSlideChange?.(slides[next].id); + }; + + /** + * Follows a card's cross-reference: pan to the slide that draws the named + * surface, then open its card. The open waits for the pan to land because + * the card measures its marker's live geometry to place itself, and an + * off-stage marker measures where it is parked, not where it will be. + */ + const goToSurface = (id: string) => { + const group = GROUP_BY_SURFACE_ID.get(id); + if (!group) return; + const target = slides.findIndex((slide) => slide.id === group.id); + if (target === -1) return; + if (target === index) { + card.open(id); + return; + } + show(target); + window.setTimeout(() => card.open(id), SLIDE_PAN_MS); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowRight") { + event.preventDefault(); + show(index + 1); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + show(index - 1); + } + }; + + const mapState = useMemo( + () => ({ + activeId: hoverId, + setActiveId: setHoverId, + // The open card is the selection, so its marker stays lit. + expandedId: card.openId, + numberOf: (id: string) => SURFACE_NUMBERS.get(id) ?? null, + onSelect: card.open, + pluginPageHref, + currentGroupId: slides[index].id, + onGoToSurface: goToSurface, + }), + // `card.open` is rebuilt each render by design: it reads live geometry. + // eslint-disable-next-line react-hooks/exhaustive-deps + [hoverId, card.openId, pluginPageHref, index], + ); + + const cardNode = openSurface ? ( + + ) : null; + // Click-away, scoped to the plugin's own UI. A pointer-down anywhere in the + // guide that is not on the open card or on a marker dismisses the card. + // Beyond the plugin's root — the pane beside it, the sidebar, bb's chrome — + // the card is left alone, so reading it while working in a split does not + // lose it. The root is the host's `[data-bb-plugin]` scoping element; with + // no host (tests, a bare render) the map's own container stands in. + useEffect(() => { + if (card.openId === null) return; + const container = containerRef.current; + if (container === null) return; + const scope = + container.closest("[data-bb-plugin]") ?? container; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest('[role="dialog"]')) return; + // A marker replaces the card with its own; let its click do that. + if (target.closest('a[href^="#surface-"]')) return; + card.close(); + }; + scope.addEventListener("pointerdown", onPointerDown); + return () => scope.removeEventListener("pointerdown", onPointerDown); + // `card.close` is a setState call behind a fresh closure each render; + // re-subscribing per open/close is all that is needed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [card.openId]); + + return ( + +
+
+ {header} + +
+ {/* The page description and, under a hairline, the navigation — + fixed above the stage so panning swaps only the diagram. */} +
+ {tone === "supporting" ? ( +

+ +

+ ) : ( +

+ +

+ )} +

+ {slides[index].blurb} +

+
+
+ show(index - 1)} + /> +
    + {slides.map((entry, slideIndex) => ( +
  • + +
  • + ))} +
+ show(index + 1)} + /> +
+
+
+ {slides.map((entry, slideIndex) => ( +
{ + slideRefs.current[slideIndex] = element; + }} + // Off-stage slides stay out of the tab order and out of + // the accessibility tree until they are panned to. + inert={slideIndex !== index} + // A taller off-stage slide would show below the stage now + // that the stage no longer clips downward, so it keeps to + // the stage's height itself. The slide on stage is never + // capped: that is what lets the composer's typeahead out. + style={ + slideIndex === index || stageHeight === null + ? undefined + : { maxHeight: stageHeight, overflow: "hidden" } + } + // Markers sit slightly outside their region; the padding + // keeps them inside the stage's clip. No shared min-height: + // the stage measures the slide on stage and animates + // between them, so a card opening below sits under the + // diagram rather than under the tallest slide's reserved + // canvas. + className="w-full shrink-0 self-start px-1 py-2" + > + +
+ ))} +
+
+ + {/* The detail card, when no gutter can hold it: in flow, tight + under the diagram, never covering it. */} + {cardNode ?
{cardNode}
: null} +
+
+
+
+ ); +} diff --git a/packages/plugin-api-map/src/surface-card.tsx b/packages/plugin-api-map/src/surface-card.tsx new file mode 100644 index 0000000000..9e151c2aa5 --- /dev/null +++ b/packages/plugin-api-map/src/surface-card.tsx @@ -0,0 +1,184 @@ +/** + * The product map's annotation card: click a numbered marker and its details + * open in flow directly below the diagram. Always below, at every width, so + * the card never covers the region it describes and never moves under the + * reader between one marker and the next. + */ +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; + +import { GROUP_BY_SURFACE_ID, type PluginSurface } from "./surfaces"; +import { + annotationChipClass, + ExperimentalBadge, + renderSurfaceCopy, + type SurfaceReference, +} from "./annotation"; +import { pluginIcon, surfaceIcon } from "./plugin-icons"; +import { UsedByList } from "./used-by"; +import { SurfaceMapContext } from "./wireframes"; + +export function SurfaceCard({ + surface, + number, + onDismiss, +}: { + surface: PluginSurface; + /** Marker number, so the card reads as the same annotation. */ + number: number | null; + onDismiss: () => void; +}) { + const cardRef = useRef(null); + // Null outside a map (the reference sidebar renders cards standalone), and + // without a resolver the names render as plain text rather than as links + // that would dead-end on "Plugin not found". + const surfaceMap = useContext(SurfaceMapContext); + const pluginPageHref = surfaceMap?.pluginPageHref; + const icon = surfaceIcon(surface.id); + const { currentGroupId, onGoToSurface, numberOf } = surfaceMap ?? {}; + // Cross-references only resolve inside the map: the number and the "which + // page" answer both come from the carousel. Elsewhere the label is prose. + const resolveReference = useCallback( + (id: string): SurfaceReference | null => { + const group = GROUP_BY_SURFACE_ID.get(id); + if (!group || !onGoToSurface) return null; + return { + number: numberOf?.(id) ?? null, + otherPage: group.id === currentGroupId ? null : group.title, + onOpen: () => onGoToSurface(id), + }; + }, + [currentGroupId, numberOf, onGoToSurface], + ); + + // Dismissal: the close button, Escape, or a click elsewhere within the + // guide's own UI (ProductMap owns that listener, scoped to the plugin + // root). Losing focus to the rest of bb — another pane, the sidebar — does + // not close it, so a card can be read while working beside it. Selecting + // another marker replaces the card, and panning to another slide closes + // it, because its marker leaves the screen. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onDismiss(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onDismiss]); + + // The card sits below the diagram, so on a short screen it can open past + // the fold; bring it into view whenever the open surface changes. + useEffect(() => { + cardRef.current?.scrollIntoView({ block: "nearest" }); + }, [surface.id]); + + return ( +
+
+ {/* A numbered surface is identified by its marker; a pixel-less one + has no marker, so it carries the same capability glyph its card on + the "Plugin backend" slide was clicked from. */} + {number === null ? ( + icon ? ( + + ) : null + ) : ( + + {number} + + )} +
+
+

+ {surface.title} +

+ {surface.experimental ? : null} +
+
+ +
+ +

+ {renderSurfaceCopy(surface.summary, resolveReference)} +

+
    + {surface.bullets.map((bullet) => ( +
  • {renderSurfaceCopy(bullet, resolveReference)}
  • + ))} +
+ + {surface.firstParty && surface.firstParty.length > 0 ? ( + // A footnote, not a second subject: the label recedes to an eyebrow + // above the border so the surface copy stays the card's content. +
+ {/* Inline lead-in, not a stacked heading: the label shares the + first baseline with the list, which keeps to one line and + drifts when it outgrows the row. */} + {/* A subtle pill: the recessed tint alone, no border and no extra + weight, so the label sits under the names it introduces. */} + + Used by + + { + const icon = pluginIcon(plugin); + const href = pluginPageHref?.(plugin) ?? null; + const body = ( + <> + {icon ? ( + + ) : null} + {plugin} + + ); + return href ? ( + + {body} + + ) : ( + + {body} + + ); + }} + /> +
+ ) : null} +
+ ); +} + +/** Tracks which surface's card is open. */ +export function useSurfaceCard() { + const [openId, setOpenId] = useState(null); + return { + openId, + open: (id: string) => setOpenId(id), + close: () => setOpenId(null), + }; +} diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts new file mode 100644 index 0000000000..d63a9a57a0 --- /dev/null +++ b/packages/plugin-api-map/src/surfaces.ts @@ -0,0 +1,743 @@ +/** + * The product-shape inventory behind the Plugin Guide: every surface a plugin + * can plug into, in plain language. This file is the whole of bb's plugin API + * documentation, so it has to stay current with the SDK — each surface names + * the SDK symbols it documents and api-sync.test.ts fails the build when the + * two drift apart. + * + * Each surface reads as plain product capability: what becomes possible in + * that part of bb once a plugin owns it. + * + * `firstParty` lists the shipped bb plugins that use each surface today, + * taken from a registration-call inventory of plugins/* in the bb repo. + */ + +export interface PluginSurface { + id: string; + title: string; + /** Lead sentence, ending in the phrase the bullets hang off. */ + summary: string; + /** What a plugin can do on this surface, one capability per line. */ + bullets: string[]; + /** + * One scannable line for capability grids; the prose stays in the detail + * card. Only the pixel-less surfaces need one today. + */ + tagline?: string; + /** + * The SDK symbols this surface documents. Not shown in the UI: it is the + * tie between a card and the API it describes, and api-sync.test.ts fails + * when one of them is renamed or removed, or when a new registration slot + * ships with no card naming its type. + */ + apiSymbols: string[]; + /** First-party bb plugins that ship on this surface today (display names). */ + firstParty?: string[]; + experimental?: boolean; +} + +export interface SurfaceGroup { + id: + | "app-shell" + | "composer" + | "home" + | "settings" + | "extensions" + | "headless"; + title: string; + blurb: string; + surfaces: PluginSurface[]; + /** + * Named clusters for a group that lists its surfaces instead of drawing + * them. Every surface id in the group appears in exactly one section + * (surfaces.test.ts enforces it). + */ + sections?: readonly { + title: string; + surfaceIds: readonly string[]; + }[]; +} + +export const SURFACE_GROUPS: SurfaceGroup[] = [ + { + id: "app-shell", + title: "The bb app window", + blurb: + "The main bb window, containing the sidebar, the conversation, and the side panel. A plugin can add rows, controls, panel tabs, and message content to the numbered regions.", + surfaces: [ + { + id: "nav-panel", + title: "Full-page panels", + summary: + "Adds a row to bb's sidebar that opens a page your plugin renders where threads normally appear. With this, a plugin can:", + bullets: [ + "Render any React you write across that whole area", + "Get its own URL, so the page can be linked to and bb's back and forward buttons work", + "Register tabs in the panel to the right of its page, beside bb's own Browser and Terminal tabs", + ], + apiSymbols: ["PluginNavPanelRegistration"], + firstParty: ["Automations", "Docs", "GitHub", "Tasks"], + }, + { + id: "thread-list", + title: "The thread list", + summary: + "Replaces the list of threads in bb's sidebar with a component your plugin renders. With this, a plugin can:", + bullets: [ + "Render every row, and decide the grouping, the ordering, and what each row shows", + "Read the same live thread data and run statuses bb's own list reads", + "Replace only the list. The New thread button, the search field, the plugin rows, and the sidebar footer stay bb's", + ], + apiSymbols: [ + "PluginThreadListRegistration", + "PluginSidebarThreadsState", + ], + experimental: true, + }, + { + id: "thread-row-status", + title: "Thread row status", + summary: + "A small status bb can draw on a thread's row in the sidebar. With this, a plugin can:", + bullets: [ + "Give the status an icon and a label", + "Mark a thread as running while it works on it, and bb shimmers the icon", + "Mark it succeeded or failed when the work ends, and bb settles the icon", + "Set it only from an [app-wide script](content-scripts). A status needs an owner that outlives any single screen, and those scripts are the only plugin code that does", + "Rely on bb to clear it when the script unmounts", + ], + apiSymbols: [ + "PluginComposerThreadRowStatus", + "PluginContentScriptContext", + ], + experimental: true, + }, + { + id: "sidebar-footer", + title: "Sidebar footer buttons", + summary: + "Adds an icon button to the row at the bottom of bb's sidebar, beside the Settings button. With this, a plugin can:", + bullets: [ + "Supply the button's icon and its hover tooltip", + "Run a callback when the button is clicked", + "Stay reachable wherever bb's sidebar is showing", + ], + apiSymbols: ["PluginSidebarFooterActionRegistration"], + firstParty: ["Remote access"], + }, + { + id: "thread-header", + title: "Thread header controls", + summary: + "Adds a control to the header bar at the top of an open thread. With this, a plugin can:", + bullets: [ + "Render a React component rather than a plain button, so it can show live state", + "Receive the id of the thread currently on screen", + "Render in the same row as bb's own header controls", + ], + apiSymbols: ["PluginThreadHeaderActionRegistration"], + experimental: true, + }, + { + id: "message-directives", + title: "Rich message embeds", + summary: + "Renders your component inside an agent's reply, in place of a marker the agent writes into its message. With this, a plugin can:", + bullets: [ + "Claim a directive name; an agent writes `::name` in a message to invoke it", + "Replace that marker with a live component, inline in the conversation", + "Open a file from the workspace when someone interacts with the embed", + ], + apiSymbols: ["PluginMessageDirectiveRegistration"], + firstParty: ["Docs", "Inline visualizations", "Tasks", "Workflows"], + }, + { + id: "message-actions", + title: "Message actions", + summary: + "Adds an action to individual messages in a thread. With this, a plugin can:", + bullets: [ + "Appear in the row that shows under messages on hover, and in the toolbar that appears when text in an agent's message is selected", + "Receive the message, plus the selected text when the action was run from a selection", + "Open one of the plugin's own [side-panel tabs](thread-panel) with what it received", + ], + apiSymbols: ["PluginMessageActionRegistration"], + firstParty: ["Side chat"], + }, + { + id: "pending-interaction", + title: "In-thread forms", + summary: + "Pauses an agent mid-turn to ask the person a question, and hands their answer back to the agent. With this, a plugin can:", + bullets: [ + "Replace the prompt box with a form while the agent waits for an answer", + "Receive the submitted answer, or a cancellation and its reason", + "Supply the component that draws the form", + ], + apiSymbols: ["PluginUi", "PluginPendingInteractionRegistration"], + firstParty: ["Ask User Question", "Secrets"], + }, + { + id: "thread-panel", + title: "Thread side-panel tabs", + summary: + "Adds a tab to the side panel that opens to the right of a thread. With this, a plugin can:", + bullets: [ + "Render the tab's contents and receive the id of the thread it was opened from", + "Open the tab from a [message action](message-actions), from the + button in the side panel, or from its own code", + ], + apiSymbols: ["PluginThreadPanelActionRegistration"], + firstParty: ["Docs", "GitHub", "Side chat", "Tasks", "Workflows"], + }, + { + id: "file-opener", + title: "File viewers & editors", + summary: + "Registers a viewer for the file types you name, so bb opens those files there instead of its built-in preview. With this, a plugin can:", + bullets: [ + "Declare the file extensions it handles, for example `.csv` or `.excalidraw`", + "Render its own viewer or editor whenever a file of that type is opened in bb", + "Receive the file's path, then read it however the plugin already reads files", + ], + apiSymbols: ["PluginFileOpenerRegistration"], + firstParty: ["Docs"], + }, + { + id: "code-renderers", + title: "Code & diff renderers", + summary: + "Replaces bb's source-code or diff renderer everywhere that kind of content appears. With this, a plugin can:", + bullets: [ + "Register the source-code and diff replacements independently", + "Apply each replacement across bb's file previews, timeline and environment diffs, and plugin pages", + "Hand any individual render back to bb's built-in renderer, and fall back to it automatically if the plugin is unavailable or crashes", + ], + apiSymbols: [ + "PluginSourceCodeRendererRegistration", + "PluginSourceCodeRendererProps", + "PluginDiffRendererRegistration", + "PluginDiffRendererProps", + ], + experimental: true, + }, + { + id: "content-scripts", + title: "App-wide scripts", + summary: + "Runs your code inside the bb window itself, without rendering a slot of its own. With this, a plugin can:", + bullets: [ + "Mount once per bb window and unmount when the window reloads", + "Add behavior that is not tied to one screen, such as a keyboard shortcut", + "Set a [thread row status](thread-row-status) on any thread, for as long as the script is mounted", + "Add its own elements to pages in the app, but not relocate bb's own", + "Return a cleanup function. bb calls it once on unmount, and clears any row statuses the script set", + ], + apiSymbols: [ + "PluginContentScriptRegistration", + "PluginContentScriptContext", + ], + }, + { + id: "command-palette-actions", + title: "Command palette actions", + summary: + "Adds a row under Plugins in bb's quick command palette. With this, a plugin can:", + bullets: [ + "Supply the row's label and run behavior; bb owns matching, ordering, and recency", + "Read the current thread and project, and hide the row when it is unavailable", + "Open one of the plugin's own thread side-panel tabs when a thread is on screen", + ], + apiSymbols: [ + "PluginCommandPaletteActionRegistration", + "PluginCommandPaletteActionContext", + ], + }, + ], + }, + { + id: "composer", + title: "The composer", + blurb: + "The prompt box used to start a thread and to reply inside one. A plugin can add banners, menu entries, and action buttons to it, answer mention searches, highlight the draft prompt, and supply the agent that runs the message.", + surfaces: [ + { + id: "composer-banners", + title: "Banners", + summary: + "Renders a banner above the prompt box. With this, a plugin can:", + bullets: [ + "Render its own component in the strip directly above the draft prompt", + "Name which prompt boxes it appears in: the new-thread screen, the follow-up composer in a thread, or a queued message being edited. Omit the list to appear in all of them", + "Show something the person should read before sending, such as a warning or a status", + ], + apiSymbols: ["ComposerCustomization", "PluginComposerScope"], + firstParty: ["Provider retry", "Workflows"], + }, + { + id: "mention-provider", + title: "Mentions", + summary: + "Adds results to the menu that opens when someone types a trigger character in the prompt box. On a trigger bb does not use itself, your plugin opens that menu and owns it. With this, a plugin can:", + bullets: [ + "Answer each keystroke after the trigger with a list of items to show", + "Claim one or more of the trigger characters @, #, $, !, and ~. Omit them to answer the default @", + "Turn a picked item into a chip in the draft prompt, and send its content to the agent along with the message", + ], + apiSymbols: [ + "PluginMentionProviderRegistration", + "PluginMentionSearchContext", + "PluginMentionItem", + ], + firstParty: ["Docs", "GitHub", "Tasks"], + }, + { + id: "composer-rich-text", + title: "Draft prompt highlighting", + summary: + "Styles ranges of the draft prompt as the person types, without changing the text. With this, a plugin can:", + bullets: [ + "Match ranges in the draft prompt, such as a ticket number or the word TODO", + "Change only how those ranges look; the text the agent receives is untouched", + "Re-run its matcher on every keystroke", + "Observe the draft prompt and its @-mentions as they change, read-only", + ], + apiSymbols: ["ComposerRichTextSpec", "ComposerStructuredDraft"], + }, + { + id: "composer-state", + title: "Draft prompt state & locking", + summary: + "Reads the draft prompt, and can block typing while the plugin works. With this, a plugin can:", + bullets: [ + "Read the draft prompt's text, whether it is empty, and how many files are attached", + "Read the prompt box's layout and whether the thread is already running a turn", + "Lock the input and release it again, so the draft prompt cannot change mid-operation", + "Mark the thread row as running while the input is locked, with a [thread row status](thread-row-status)", + ], + apiSymbols: ["ComposerView", "PluginComposerApi"], + }, + { + id: "composer-plus-menu", + title: "The + menu", + summary: + "Adds rows to the menu that opens from the + button beside the prompt box. With this, a plugin can:", + bullets: [ + "Supply each row's icon, label, and disabled state; bb renders the row itself", + "Run a callback when someone picks the row", + "Read and rewrite the draft prompt from that callback", + ], + apiSymbols: ["ComposerPlusMenuItem"], + }, + { + id: "provider-picker", + title: "Agent providers", + summary: + "Adds an agent to bb's model picker and runs the threads started with it. With this, a plugin can:", + bullets: [ + "Appear in the model picker beside bb's built-in providers", + "Declare what the provider supports, then serve its model list at runtime", + "Supply a small icon that appears next to its name", + "Receive every message in a thread started with it, through a bridge process the plugin ships", + ], + apiSymbols: [ + "PluginProviderDeclaration", + "PluginProviderIconRegistration", + ], + firstParty: [ + "ACP providers", + "Claude Code provider", + "Codex provider", + "Pi provider", + ], + experimental: true, + }, + { + id: "composer-actions", + title: "Inline actions", + summary: + "Adds a button to the row of controls inside the prompt box, beside the voice and send buttons. With this, a plugin can:", + bullets: [ + "Read and rewrite the draft prompt, for example rephrasing it or inserting a template", + "Insert a quoted passage, or an @-mention that resolves through its own mention provider", + "Lock the input while it works, and tint the whole draft while it does", + "Render in the same row as bb's own prompt-box buttons. If you have more than 3 plugins enabled, bb keeps the 3 most-used plugins inline and moves the rest into an overflow menu", + ], + apiSymbols: ["PluginComposerApi"], + }, + ], + }, + { + id: "home", + title: "Home page", + blurb: + "The screen bb opens on, holding the new-thread composer and a side panel. A plugin can add a section below the composer, and an action in that panel that opens its own tab.", + surfaces: [ + { + id: "homepage-section", + title: "Home-screen sections", + summary: + "Adds a full-width section to the page bb opens on, below the prompt box. With this, a plugin can:", + bullets: [ + "Render its own component across the width of the content area", + "Render before any thread exists, which suits shortcuts and pinned work", + "Render after bb's own content, in the order plugins registered", + ], + apiSymbols: ["PluginHomepageSectionRegistration"], + }, + { + id: "new-thread-panel", + title: "New-thread side panel", + summary: + "Adds a tab to the side panel beside the new-thread screen. It is the [thread side panel](thread-panel) for a thread that does not exist yet. With this, a plugin can:", + bullets: [ + "Render before a thread exists, so it receives no thread id", + "Host setup the person does while writing the first prompt", + "Receive the project selected in the prompt box", + ], + apiSymbols: ["PluginNewThreadPanelActionRegistration"], + experimental: true, + }, + ], + }, + { + id: "settings", + title: "Plugin settings page", + blurb: + "The settings page bb creates for every installed plugin. A plugin can declare fields for bb to render and add its own section below them.", + surfaces: [ + { + id: "declarative-settings", + title: "Settings fields", + summary: + "Declares the settings your plugin needs as plain data; bb renders the form for them on the plugin's settings page and stores the values. With this, a plugin can:", + bullets: [ + "Declare each field's type (text, toggle, choice, or project) with a label and an optional default", + "Get the form, its validation, and saving without writing any UI", + "Mark a text field secret: bb stores it in a protected file on the server and never sends it to the browser", + "Read saved values from its server code, or the non-secret ones from its own UI with `useSettings()`", + ], + apiSymbols: [ + "PluginSettings", + "PluginSettingDescriptor", + "PluginSettingsState", + ], + firstParty: ["GitHub", "Provider retry", "Workflows"], + }, + { + id: "settings-section", + title: "Custom settings section", + summary: + "Renders your own React component on the plugin's settings page, below the [fields bb generated](declarative-settings). Use it for anything that is not a value in a form. With this, a plugin can:", + bullets: [ + "Render whatever UI it needs, such as a connect-account button, a test-connection result, or a preview", + "Run in the browser, so it stores nothing itself. It calls the plugin's own backend to do that", + "Supply a heading and a one-line description for bb to render above it", + ], + apiSymbols: ["PluginSettingsSectionRegistration"], + firstParty: [ + "Custom instructions", + "Keep Awake", + "Memory", + "Remote access", + ], + }, + ], + }, + { + id: "extensions", + title: "Plugin page in Extensions", + blurb: + "The page bb shows for an installed plugin under Extensions: what it is, what it registers, and whether it is healthy. A plugin can report that it needs configuring, and bb says so at the top of this page.", + surfaces: [ + { + id: "plugin-status", + title: "Configuration status", + summary: + "Reports that the plugin cannot run until someone configures it, so bb can say so instead of the plugin failing silently. With this, a plugin can:", + bullets: [ + "Set a needs-configuration state with a message naming what is missing", + "Show a warning banner with that message on the plugin's page in Extensions", + ], + apiSymbols: ["PluginStatusApi"], + firstParty: ["GitHub", "Workflows"], + }, + ], + }, + { + id: "headless", + title: "Plugin backend", + // The grid below names all ten capabilities with their own taglines, so + // the blurb does not list them again. + blurb: "The parts of the plugin API with no interface of their own.", + sections: [ + { + title: "Commands & agent capabilities", + surfaceIds: ["cli", "agent-tools"], + }, + { + title: "Running & reacting", + surfaceIds: ["background", "wire", "thread-events", "host-workers"], + }, + { + title: "Data & platform", + surfaceIds: ["storage", "bb-sdk", "host-components"], + }, + { + title: "Confidence", + surfaceIds: ["testing"], + }, + ], + surfaces: [ + { + id: "cli", + tagline: "Your own `bb ` command", + title: "bb CLI commands", + summary: + "Registers a top-level `bb ` command, available in the terminal and to agents. With this, a plugin can:", + bullets: [ + "Be invoked the same way by a person at a terminal and by an agent mid-task", + "Receive the thread and project it was invoked from, when bb knows them", + "Make the plugin usable from scripts and automations, not only from the UI", + ], + apiSymbols: ["PluginCli"], + firstParty: [ + "Automations", + "Custom instructions", + "Docs", + "GitHub", + "Keep Awake", + "Memory", + "Provider retry", + "Remote access", + "Secrets", + "Tasks", + "Workflows", + ], + }, + { + id: "agent-tools", + tagline: "Native tools, skills, and instructions in every session", + title: "Agent tools & skills", + summary: + "Adds tools, skills, and instructions to the agent sessions bb runs. With this, a plugin can:", + bullets: [ + "Register tools an agent calls the same way it calls bb's built-in tools", + "Decide per thread which of its tools and skills are available", + "Append instructions to a session's system prompt as that session starts", + ], + apiSymbols: ["PluginAgents"], + firstParty: [ + "Ask User Question", + "Custom instructions", + "Memory", + "Remote access", + "Workflows", + ], + }, + { + id: "background", + tagline: "Supervised services and cron schedules", + title: "Background work", + summary: + "Runs code on the bb server when no window is open. With this, a plugin can:", + bullets: [ + "Register long-running services that bb starts, supervises, and restarts after a failure", + "Register jobs that run on a cron schedule", + "Be told to shut down cleanly before it reloads or is disabled", + ], + apiSymbols: ["PluginBackground"], + firstParty: [ + "Automations", + "Docs", + "GitHub", + "Keep Awake", + "Provider retry", + "Remote access", + "Side chat", + "Tasks", + "Workflows", + ], + }, + { + id: "wire", + tagline: "Typed RPC, webhook routes, realtime push", + title: "HTTP, RPC & realtime", + summary: + "Connects the plugin's own UI, its server code, and outside services. With this, a plugin can:", + bullets: [ + "Call its server from its UI over RPC, with arguments and results checked against a schema", + "Serve HTTP routes other systems can call, webhooks included", + "Push messages to every open bb window, so the UI does not have to poll", + ], + apiSymbols: ["PluginRpc", "PluginHttp", "PluginRealtime"], + firstParty: [ + "Automations", + "Custom instructions", + "Docs", + "GitHub", + "Inline visualizations", + "Keep Awake", + "Memory", + "Provider retry", + "Remote access", + "Side chat", + "Tasks", + "Workflows", + ], + }, + { + id: "storage", + tagline: "Namespaced KV plus your own SQLite", + title: "Storage", + summary: + "Stores the plugin's data on the bb server. With this, a plugin can:", + bullets: [ + "Get a key-value store for small values such as flags and cursors", + "Get its own SQLite database, with migrations, for larger or relational data", + "Read and write only its own namespace; other plugins cannot see it", + ], + apiSymbols: ["PluginStorage"], + firstParty: [ + "Automations", + "Custom instructions", + "Docs", + "GitHub", + "Keep Awake", + "Memory", + "Remote access", + "Side chat", + "Tasks", + "Workflows", + ], + }, + { + id: "thread-events", + tagline: "React when threads start, finish, or fail", + title: "Thread lifecycle events", + summary: + "Runs server code when a thread changes state. With this, a plugin can:", + bullets: [ + "Subscribe to threads being created, going active or idle, failing, being archived, or being deleted", + "Receive a typed payload describing the thread and the transition", + "Respond by sending a notification, retrying, or writing to its own storage", + ], + apiSymbols: ["PluginEvents", "PluginThreadEventPayloads"], + firstParty: ["Automations", "Provider retry", "Tasks", "Workflows"], + }, + { + id: "host-workers", + tagline: "Run code on enrolled machines", + title: "Host workers", + summary: + "Runs the plugin's code on an enrolled machine, not only on the bb server. With this, a plugin can:", + bullets: [ + "Ship a Node entry point bb starts on demand on the machine it calls", + "Call that worker from its server code over typed RPC", + "Do work that has to happen on the machine itself, such as watching files or holding a wake lock", + ], + apiSymbols: ["PluginHosts"], + firstParty: ["Keep Awake", "Remote access"], + experimental: true, + }, + { + id: "bb-sdk", + tagline: "Create threads and projects from plugin code", + title: "The bb SDK", + summary: + "Calls bb's own API from the plugin's server code. With this, a plugin can:", + bullets: [ + "Create threads, send messages to them, and manage projects", + "Reach the same operations the [bb CLI](cli) and the bb UI use", + "Have the threads it creates attributed back to the plugin", + ], + apiSymbols: ["BbPluginApi"], + firstParty: [ + "Automations", + "Docs", + "GitHub", + "Inline visualizations", + "Keep Awake", + "Provider retry", + "Secrets", + "Side chat", + "Tasks", + "Workflows", + ], + }, + { + id: "host-components", + tagline: "Embed bb's chat and prompt box", + title: "Host components", + summary: + "Renders bb's own conversation and prompt-box components inside the plugin's pages. With this, a plugin can:", + bullets: [ + "Embed the thread view and the new-thread prompt box as components", + "Render message text with the same Markdown renderer bb uses", + "Inherit bb's styling, so embedded UI matches the rest of the app", + ], + apiSymbols: [ + "ThreadChat", + "Markdown", + "experimental_NewThreadComposer", + ], + firstParty: ["Side chat"], + }, + { + id: "testing", + tagline: "Unit-test every surface without a running bb", + title: "Testing harnesses", + summary: + "Tests the plugin without a running bb. With this, a plugin can:", + bullets: [ + "Run its server code against an in-process fake of the bb server", + "Render its UI slots under vitest and jsdom", + "Drive its host worker with no host daemon running", + ], + apiSymbols: [ + "createFakePluginHost", + "renderSlot", + "createFakeSdk", + "experimental_createHostEntryHarness", + ], + firstParty: [ + "Ask User Question", + "Automations", + "Custom instructions", + "Docs", + "GitHub", + "Inline visualizations", + "Keep Awake", + "Memory", + "Provider retry", + "Remote access", + "Secrets", + "Side chat", + "Tasks", + "Workflows", + ], + }, + ], + }, +]; + +/** + * Which slide each surface is drawn on, so a card that names another surface + * can say where to find it — the same page's marker number, or the other + * page by name. + */ +export const GROUP_BY_SURFACE_ID: ReadonlyMap< + string, + { id: SurfaceGroup["id"]; title: string } +> = new Map( + SURFACE_GROUPS.flatMap((group) => + group.surfaces.map( + (surface) => [surface.id, { id: group.id, title: group.title }] as const, + ), + ), +); + +export const SURFACES_BY_ID: ReadonlyMap = new Map( + SURFACE_GROUPS.flatMap((group) => + group.surfaces.map((surface) => [surface.id, surface] as const), + ), +); diff --git a/packages/plugin-api-map/src/used-by.tsx b/packages/plugin-api-map/src/used-by.tsx new file mode 100644 index 0000000000..0253f2c9be --- /dev/null +++ b/packages/plugin-api-map/src/used-by.tsx @@ -0,0 +1,235 @@ +/** + * The "Used by" row: the shipped plugins that ship on a surface. + * + * A short list renders as one plain row, exactly as it always has. A list too + * long for the row keeps its single line and scrolls sideways, driven by the + * reader: carets page through it, and the trackpad, wheel, drag, and arrow + * keys all work because the row is an ordinary scroll container. + * + * Nothing moves on its own, so there is no motion to suppress under + * `prefers-reduced-motion`; that setting only decides whether a caret click + * animates or jumps. + */ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; + +import { cn } from "./cn"; + +/** Sub-pixel slack: a 0.5px remainder is not something left to scroll to. */ +const SCROLL_EPSILON_PX = 1; +/** Kept on screen across a paged scroll, so the eye has an anchor. */ +const SCROLL_OVERLAP_PX = 32; +/** Floor for the step, so a very narrow row still advances usefully. */ +const MIN_SCROLL_STEP_PX = 80; + +export interface UsedByScrollState { + canScrollLeft: boolean; + canScrollRight: boolean; +} + +/** Geometry of a scroll container, as much of it as these helpers need. */ +export interface UsedByScrollMetrics { + scrollLeft: number; + scrollWidth: number; + clientWidth: number; +} + +/** + * Which carets to offer. Both false means everything fits and the row shows + * no scroll affordance at all. + * + * Pure so the extents are testable without a layout engine. + */ +export function usedByScrollState({ + scrollLeft, + scrollWidth, + clientWidth, +}: UsedByScrollMetrics): UsedByScrollState { + const maxScroll = scrollWidth - clientWidth; + if (maxScroll <= SCROLL_EPSILON_PX) { + return { canScrollLeft: false, canScrollRight: false }; + } + return { + canScrollLeft: scrollLeft > SCROLL_EPSILON_PX, + canScrollRight: scrollLeft < maxScroll - SCROLL_EPSILON_PX, + }; +} + +/** Roughly one visible width, less an overlap so nothing jumps past unread. */ +export function usedByScrollStep(clientWidth: number): number { + return Math.max(clientWidth - SCROLL_OVERLAP_PX, MIN_SCROLL_STEP_PX); +} + +/** The minimum a caret needs from its viewport, so tests can stand one in. */ +export interface UsedByScrollTarget { + clientWidth: number; + scrollBy(options: { left: number; behavior: ScrollBehavior }): void; +} + +/** One caret press: a page in `direction`, instant when motion is reduced. */ +export function scrollUsedBy( + viewport: UsedByScrollTarget, + direction: -1 | 1, + { reducedMotion }: { reducedMotion: boolean }, +): void { + viewport.scrollBy({ + left: direction * usedByScrollStep(viewport.clientWidth), + behavior: reducedMotion ? "auto" : "smooth", + }); +} + +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!query) { + return; + } + const sync = () => setReduced(query.matches); + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, []); + return reduced; +} + +function Caret({ + direction, + shown, + onClick, +}: { + direction: "left" | "right"; + shown: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function UsedByList({ + items, + renderItem, +}: { + items: readonly string[]; + renderItem: (item: string) => ReactNode; +}) { + const viewportRef = useRef(null); + const [scroll, setScroll] = useState({ + canScrollLeft: false, + canScrollRight: false, + }); + const reducedMotion = useReducedMotion(); + + const sync = useCallback(() => { + const viewport = viewportRef.current; + if (viewport) { + setScroll(usedByScrollState(viewport)); + } + }, []); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) { + return; + } + sync(); + // The viewport resizes with the card; the row inside it resizes with the + // items. Either changes what is left to scroll to. + const observer = new ResizeObserver(sync); + observer.observe(viewport); + const row = viewport.firstElementChild; + if (row) { + observer.observe(row); + } + viewport.addEventListener("scroll", sync, { passive: true }); + return () => { + observer.disconnect(); + viewport.removeEventListener("scroll", sync); + }; + }, [items, sync]); + + const page = (direction: -1 | 1) => { + const viewport = viewportRef.current; + if (viewport) { + scrollUsedBy(viewport, direction, { reducedMotion }); + } + }; + + const scrollable = scroll.canScrollLeft || scroll.canScrollRight; + + return ( +
+ {scrollable ? ( + page(-1)} + /> + ) : null} +
{ + if ( + scrollable && + (event.key === "ArrowLeft" || event.key === "ArrowRight") + ) { + event.stopPropagation(); + } + }} + className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden" + > +
    + {items.map((item) => ( +
  • + {renderItem(item)} +
  • + ))} +
+
+ {scrollable ? ( + page(1)} + /> + ) : null} +
+ ); +} diff --git a/packages/plugin-api-map/src/wireframes.tsx b/packages/plugin-api-map/src/wireframes.tsx new file mode 100644 index 0000000000..d9358ac58b --- /dev/null +++ b/packages/plugin-api-map/src/wireframes.tsx @@ -0,0 +1,1572 @@ +/** + * Miniature mockups of the real bb UI with every pluggable surface marked. + * Layout and ordering mirror the real components in apps/app (audited against + * AppSidebar, ThreadDetailHeader, ConversationMessageContent, MessageActionBar, + * FollowUpPromptBox/PromptBoxInternal, ThreadSecondaryPanel, RootComposeView, + * and PluginSettings); plugin contributions render highlighted, in the exact + * spot the host inserts them. + * + * The regions covered by anatomy-manifest.json (sidebar sections, sidebar + * footer, message action bar) render FROM the manifest, and a test in + * apps/app renders the real components and asserts the same DOM order, so an + * app-side reorder fails tests until the manifest, and these skeletons, + * update. + * + * Marks are anchors that expand the matching sidebar row and sync hover state + * through SurfaceMapContext. The exported *_MARKS arrays are the contract with + * surfaces.ts: surfaces.test.ts asserts every surface in a visual group is + * marked exactly once. + */ +import { createContext, Fragment, useContext, type ReactNode } from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; +import { + ArrowLeft01Icon, + ArrowUp01Icon, + ArrowRight01Icon, + Bug01Icon, + Copy01Icon, + File01Icon, + Folder01Icon, + GitBranchIcon, + InformationCircleIcon, + Mic01Icon, + MoreHorizontalIcon, + PencilEdit01Icon, + PlusSignIcon, + ElectricPlugsIcon, + Search01Icon, + Settings02Icon, + SparklesIcon, + PlusMinusSquare01Icon, + SidebarLeftIcon, + SidebarRightIcon, + ToolboxIcon, + TerminalIcon, +} from "@hugeicons/core-free-icons"; + +import { cn } from "./cn"; +import { annotationChipClass } from "./annotation"; +import anatomy from "./anatomy-manifest.json"; + +export interface SurfaceMapState { + activeId: string | null; + setActiveId: (id: string | null) => void; + /** + * The surface whose sidebar row is open. Markers use it alongside + * `activeId` so a marker and its row are never in different states. + */ + expandedId?: string | null; + /** + * When set, only this surface's marker stays lit; every other region of the + * skeleton recedes. Lets one diagram serve as a per-surface illustration + * instead of shipping a cropped image per surface. + */ + spotlightId?: string | null; + numberOf: (id: string) => number | null; + /** + * Resolves a shipped plugin's page URL, or null when this host has no page + * for it. Supplied by the bb plugin, which can ask the running host; the + * docs website has no host and so supplies nothing. + */ + pluginPageHref?: (displayName: string) => string | null; + /** + * When provided, clicking a marker calls this instead of following the + * `#surface-` anchor — the sidebar-nav layout uses it to expand the + * matching nav row in place. + */ + onSelect?: (id: string) => void; + /** + * The slide currently on stage, so a card naming another surface can tell + * whether that surface is on this page or another one. + */ + currentGroupId?: string; + /** + * Pans to the slide holding a surface and opens its card. Absent outside + * the carousel, where there is nothing to pan. + */ + onGoToSurface?: (id: string) => void; +} + +export const SurfaceMapContext = createContext(null); + +export function useSurfaceMap(): SurfaceMapState { + const state = useContext(SurfaceMapContext); + if (!state) { + throw new Error("useSurfaceMap must be used inside a SurfaceMapContext"); + } + return state; +} + +export const APP_SHELL_MARKS = [ + "nav-panel", + "thread-list", + "thread-row-status", + "sidebar-footer", + "thread-header", + "message-directives", + "message-actions", + "pending-interaction", + "thread-panel", + "file-opener", + "code-renderers", + "content-scripts", + "command-palette-actions", +] as const; + +export const COMPOSER_MARKS = [ + "composer-banners", + "mention-provider", + "composer-rich-text", + "composer-state", + "composer-plus-menu", + "provider-picker", + "composer-actions", +] as const; + +export const COMPOSE_MARKS = ["homepage-section", "new-thread-panel"] as const; + +export const EXTENSIONS_MARKS = ["plugin-status"] as const; + +export const SETTINGS_MARKS = [ + "declarative-settings", + "settings-section", +] as const; + +/* ── primitives ─────────────────────────────────────────────────────── */ + +function Mark({ + id, + label, + className, + chipClassName, + edge = false, + children, +}: { + id: string; + label: string; + className?: string; + chipClassName?: string; + /** + * Anchor the chip to the diagram's gutter instead of to this mark, for a + * region that hugs an outer edge — those chips crowd the diagram's own + * chrome when they sit inside it. + * + * The mechanism is the containing block: dropping `relative` here makes + * the chip resolve against the nearest positioned ancestor, which is the + * `relative` gutter wrapper outside the frame. An absolutely positioned + * box is not clipped by an ancestor whose descendant its containing block + * is not, so the chip escapes both the frame's `overflow-hidden` and the + * scroll wrapper's `overflow-x-auto` — and `chipClassName` is then read as + * coordinates on the gutter, not on the mark. + */ + edge?: boolean; + children?: ReactNode; +}) { + const { activeId, setActiveId, expandedId, spotlightId, numberOf, onSelect } = + useSurfaceMap(); + const active = activeId === id || expandedId === id || spotlightId === id; + // The container outline is exclusive: while any region is hovered, only + // that region outlines, so two outlines are never on screen to overlap. + // The chip fill still follows `active`, so an open card's marker stays lit. + const outlined = + activeId !== null + ? activeId === id + : expandedId === id || spotlightId === id; + const dimmed = Boolean(spotlightId) && spotlightId !== id; + return ( + { + event.preventDefault(); + // A marker inside another marked region (the provider glyph in + // the picker, the painted range in the draft) must open its own + // card, not the enclosing one's. + event.stopPropagation(); + onSelect(id); + } + : undefined + } + onMouseEnter={() => setActiveId(id)} + onMouseLeave={() => setActiveId(null)} + onFocus={() => setActiveId(id)} + onBlur={() => setActiveId(null)} + className={cn( + // ring-inset keeps the outline inside this region's own bounds, so + // it cannot bleed into a neighbor that shares an edge. + "rounded-md ring-1 ring-inset transition-all", + edge || "relative", + outlined + ? "bg-surface-selected ring-surface-selected-border" + : "ring-transparent hover:bg-state-hover", + dimmed && "opacity-25", + className, + )} + > + {/* Markers ship in the prominent ink fill so they read as the page's + interactive layer; the selected one switches to the timeline file + accent. The ring punches the chip out of the mockup's grey bones. */} + + {numberOf(id)} + + {children} + + ); +} + +/** + * An annotation whose boundary is the fixture element it describes. + * + * Unlike OverlayMark, this component does not measure a rectangle against a + * slide. Its interactive layer fills the content wrapper, so the outline and + * marker move with that content. The overlay is a sibling of `children`, so + * fixture content does not have to become part of the interactive anchor. + */ +function RegionMark({ + id, + label, + className, + chipClassName, + children, +}: { + id: string; + label: string; + className?: string; + chipClassName?: string; + children: ReactNode; +}) { + const { activeId, setActiveId, expandedId, spotlightId, numberOf, onSelect } = + useSurfaceMap(); + const active = activeId === id || expandedId === id || spotlightId === id; + const outlined = + activeId !== null + ? activeId === id + : expandedId === id || spotlightId === id; + const dimmed = Boolean(spotlightId) && spotlightId !== id; + + return ( + + ); +} + +function MiniIcon({ + icon, + className, +}: { + icon: IconSvgElement; + className?: string; +}) { + return ( + + ); +} + +/** A plugin-contributed control: electric-plug glyph, drawn in the ink color. */ +function PluginGlyph({ className }: { className?: string }) { + return ( + + ); +} + +function WindowFrame({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function TrafficLights() { + return ( + + + + + + ); +} + +/* ── the main app window ────────────────────────────────────────────── */ + +const SIDEBAR_THREADS: readonly { title: string; glyph?: "spin" | "dot" }[] = [ + { title: "Fix flaky checkout tests", glyph: "spin" }, + { title: "Refactor settings page" }, + { title: "Ship dark mode", glyph: "dot" }, +]; + +/** + * Sidebar footer icons, in anatomy-manifest order: Settings, then plugin + * footer actions, then Report a bug (mirrors AppSidebar's SidebarFooter). + */ +const FOOTER_ITEM_RENDERERS: Record ReactNode> = { + settings: () => , + "plugin-footer-actions": () => ( + + + + ), + "bug-report": () => , +}; + +/** + * Sidebar sections, in anatomy-manifest order (mirrors AppSidebar.tsx: + * top-reserve chrome, the New-thread/search block, plugin nav rows, the + * scrolling thread list, the footer). + */ +const SIDEBAR_SECTION_RENDERERS: Record ReactNode> = { + "top-reserve": () => ( +
+ + + + +
+ ), + "primary-actions": () => ( +
+ + + New thread + + +
+ ), + "plugin-nav": () => ( + + + + Extensions + + {/* The active row uses the sidebar's own accent, exactly like the + real nav row (PluginNavSidebarItems). */} + + + Your panel + + + ), + "thread-list": () => ( + + + Pinned + + {SIDEBAR_THREADS.map((thread) => ( + + {thread.title} + {thread.glyph === "spin" ? ( + // A running status on the row: the glyph a plugin's thread row + // status replaces. Its own marker, inside the thread list's. + + + + ) : thread.glyph === "dot" ? ( + + ) : null} + + ))} + + Projects + + {["acme-app", "dotfiles"].map((project) => ( + + {project} + + + ))} + + ), + footer: () => ( + + {anatomy.sidebarFooter.map((key) => ( + {FOOTER_ITEM_RENDERERS[key]?.()} + ))} + + ), +}; + +/** + * Message action bar icons, in anatomy-manifest order: the five host actions, + * then plugin actions (mirrors MessageActionBar.tsx). + */ +const MESSAGE_ACTION_RENDERERS: Record ReactNode> = { + copy: () => , + edit: () => , + "add-to-chat": () => , + "send-to-main-thread": () => ( + + ), + fork: () => , + "plugin-actions": () => , +}; + +/** Registry coverage, checked against the manifest by surfaces.test.ts. */ +export const ANATOMY_RENDERER_KEYS = { + appSidebar: Object.keys(SIDEBAR_SECTION_RENDERERS), + sidebarFooter: Object.keys(FOOTER_ITEM_RENDERERS), + messageActionBar: Object.keys(MESSAGE_ACTION_RENDERERS), +}; + +export function AppShellWireframe() { + return ( + // The padding is the annotation gutter: edge-hugging markers anchor to + // this box and sit outside the frame, so they ring the diagram instead + // of crowding its chrome. +
+ {/* Content scripts have no slot of their own — they run across the + whole window, so the marker annotates the frame itself. */} + + {/* Three fixed-ish columns need ~720px; small windows scroll the + mockup horizontally instead of losing the panel and its markers. */} +
+
+ +
+
+
+ ); +} + +function AppShellWireframeBody() { + return ( + + +
+
+ Commands +
+
+ + Your plugin: run action + + Plugins + +
+
+
+ {/* Sized to the real window's aspect: at the diagram's 832px width, a + ~523px frame matches the ~1.6:1 footprint of an actual bb window. + The thread list and timeline are flex-1, so the height lands there + as open canvas. */} +
+ {/* ── sidebar, sections in anatomy-manifest order ── */} +
+ {anatomy.appSidebar.map((key) => ( + {SIDEBAR_SECTION_RENDERERS[key]?.()} + ))} +
+ + {/* ── thread view ── */} +
+ {/* header: title left; plugin action leads the right action row */} +
+ + Fix flaky checkout tests + + + + + + +
+ + {/* timeline */} +
+ {/* user message: right-aligned bubble */} +
+ + Fix the flaky checkout tests + +
+ + {/* assistant message: plain prose + directive + action bar */} +
+

+ The retries cluster in two suites. Failure rate by suite: +

+ + + + + + + + + + + ::your-directive + + + +
+ + + 1 + + + + + + 2 + + + + + + 3 + + + +
+
+

+ Fixed by isolating the Stripe mock per test. +

+ {/* action bar, icons in anatomy-manifest order */} + + {anatomy.messageActionBar.map((key) => ( + + {MESSAGE_ACTION_RENDERERS[key]?.()} + + ))} + +
+
+ + {/* pending interaction: replaces the prompt box, not the timeline */} +
+ + + + Pick a release channel + + + + + Cancel + + + Submit + + + +
+
+ + {/* ── right panel (ThreadSecondaryPanel) ── */} + {/* Plain bg-sidebar, like the real ThreadSecondaryPanel — the real + panel is not the app's `.fixed.bg-sidebar` element, so it does + not get the themed sidebar overlay (grain, edge piping). */} +
+ {/* Toolbar, in ThreadSecondaryPanel's own order: the pinned Info + and Diff views as icon-only pills, then the strip of open-view + pills, then New tab — and, pushed to the far end, the hide + control. No separator rule: the real row has none. */} +
+ + + + + + + {/* An open view, pill-shaped like the rest of the strip. It keeps + its label off so the plugin's tab beside it stays readable at + the diagram's width; the real pill carries one. */} + + + + + + Your tab + + + + +
+ {/* body: a plugin-owned file preview, separate from the source and + diff renderer shown in the timeline */} +
+ +
+
+ + notes.md + +
+

Checkout retry notes

+

+ Flakes cluster around shared test state. Reset each mock + between cases before rerunning the suite. +

+
+
+
+
+
+
+ ); +} + +/* ── the composer, close up (FollowUpPromptBox order) ───────────────── */ + +export function ComposerWireframe() { + return ( +
+ {/* banners: plugin banners render first, above the card */} + + + Your banner + +
+ + Uncommitted · 3 files +
+ + {/* mention menu: opens above the input in the follow-up composer */} + + + Your plugin + + + + release-notes + + + + roadmap + + + + {/* the prompt card */} +
+ {/* The editable draft: what useComposerView reads and setInputLock + holds. Marked on the text block itself, not on the card. */} + +

+ Summarize{" "} + + @release-notes + {" "} + and fix the{" "} + + + TODO + + {" "} + in checkout +

+
+ + {/* bottom row: + menu, model picker; then plugin actions, mic, send */} +
+ {/* Drawn pressed: its menu is open below the card. */} + + + + + + + Your model + High + + + + + + + + + + + + +
+
+ + {/* + menu: opens under the + at the card's bottom-left corner. Drawn + open — like the mention menu above — so both of the composer's + menus, and the plugin row inside this one, are visible at once; + something the live composer can never show. */} + + + + Attach files + + + + Skills + + + + Your action + + + + {/* the strip below the card: environment left, permission mode right */} +
+ + + acme-app · worktree + + Full Access +
+
+ ); +} + +/* ── annotating the real composer (bb plugin only) ──────────────────── */ + +/** + * Marker for a real host component: the numbered chip, plus an optional + * highlight rectangle over the region it points at. The highlight uses the + * same selected-surface tokens the skeleton `Mark` regions use, so a live + * component and a mockup light up the same way. + */ +function OverlayMark({ + id, + label, + className, + region, +}: { + id: string; + label: string; + /** Chip position, relative to the annotated container. */ + className?: string; + /** Region to highlight while active, as inset utilities. */ + region?: string; +}) { + const { activeId, setActiveId, expandedId, spotlightId, numberOf, onSelect } = + useSurfaceMap(); + const active = activeId === id || expandedId === id || spotlightId === id; + // Exclusive, like Mark's outline: hovering any region shows that region's + // ring alone, so overlapping regions (the draft line contains the mention + // pill and the painted range) never draw two rings at once. + const outlined = + activeId !== null + ? activeId === id + : expandedId === id || spotlightId === id; + return ( + <> + {region && outlined ? ( + + ) : null} + { + event.preventDefault(); + onSelect(id); + } + : undefined + } + onMouseEnter={() => setActiveId(id)} + onMouseLeave={() => setActiveId(null)} + onFocus={() => setActiveId(id)} + onBlur={() => setActiveId(null)} + // Below the expanded menus (z-10): an open menu should cover resting + // chips behind it, exactly as a real popover would. + className={cn("absolute z-[6]", className)} + > + + {numberOf(id)} + + + + ); +} + +/** + * The composer slide inside bb: the real host composer, rendered static, + * seated in the thread chrome it actually lives in — window bar, a short + * exchange above, the reply box at the bottom. The chrome is what gives the + * slide its height, so the page matches the other slides without padding. + * + * The component is the one the plugin API actually returns + * (experimental_NewThreadComposer); the wrapper is `inert`, so nothing + * focuses, types, or opens. Over the editor's first line sits a drawn draft + * with a real mention pill and a painted range — the two things a plain-text + * seed cannot show. The composer's two menus stay collapsed; each expands + * only while its own annotation is engaged. The + menu opens upward, as the + * real one does when the composer sits at the bottom of the window. + */ +export function RealComposerAnnotated({ composer }: { composer: ReactNode }) { + const { activeId, expandedId } = useSurfaceMap(); + const engaged = (id: string) => activeId === id || expandedId === id; + return ( + // The same gutter geometry as the other window slides, so the nav above + // and the card below sit the same distance from every frame. +
+ {/* Below ~720px the annotated composer scrolls sideways rather than + reflowing: the overlay markers are measured against the full-width + layout, so the diagram keeps that layout at every panel width. */} +
+
+ +
+ {/* thread chrome: header and a short exchange, unannotated */} + {/* Full-scale chrome (text-sm rows, 44px header, 16px icons): the + real composer renders at product size below, so the drawn + thread around it holds the same scale instead of miniature. */} +
+ + Ship the release notes + + + +
+
+
+ + Draft the release notes + +
+

+ Drafted. Two rough edges left in checkout — reply with what to + fold in. +

+
+ + {/* the reply box, pinned to the bottom like the real one, at the + real product's footprint: the actual composer spans ~two thirds + of the thread column, not edge to edge. */} +
+ {/* banner: a plugin banner renders in this slot, above the box */} + + + Your banner + + +
+
{composer}
+ + {/* The drawn draft, covering the editor's first line: a real + mention pill and a plugin-painted range. Its fill matches + the prompt box's own background, so it disappears into the + product chrome instead of reading as a pasted-on strip. */} +
+ + Summarize{" "} + + + @release-notes + + + {" "} + and fix the{" "} + + + TODO + + + {" "} + in checkout. + +
+ + {/* the draft itself: what useComposer reads and locks */} + + {/* The mention pill and highlighted range carry their own + RegionMarks above, so their boundaries follow the + rendered fixture text rather than slide coordinates. */} + {engaged("mention-provider") ? ( +
+ + Your plugin + + + + release-notes + + + + roadmap + +
+ ) : null} + + {/* + menu: chip on the + itself; opens upward while engaged, + the direction the real menu takes at the window's bottom */} + + {engaged("composer-plus-menu") ? ( +
+ + + Attach files + + + + Skills + + + + Your action + +
+ ) : null} + + {/* agent providers: one annotation for the whole picker */} + + {/* The real composer intentionally suppresses globally + installed plugin controls. Draw this fixture-owned icon + in the documented slot so the annotation still points + at a recognizable plugin action. */} + + + + + +
+
+
+
+
+
+
+ ); +} + +/* ── the new-thread screen (RootComposeView order) ──────────────────── */ + +export function ComposeScreenWireframe({ + composer, +}: { + /** The host's real composer, when available; replaces the mock one. */ + composer?: ReactNode; +} = {}) { + return ( + // Padded for the same annotation gutter as the app-window diagram. +
+
+
+ +
+
+
+ ); +} + +function ComposeScreenWireframeBody({ composer }: { composer?: ReactNode }) { + return ( + +
+ +
+ {/* Proportions mirror RootComposeView: a centered reading column + (max-w-[760px] in the real app) inside a much wider main area, + content top-aligned, empty canvas below. */} +
+
+
+ {/* the composer, no greeting above it (RootComposeView order): + the real one when the host lends it, the mock otherwise. + Inert either way — this is a diagram, and a live menu opening + here would cover the marked section below it. Width-capped to + the real home page's ratio: the product's composer spans about + two thirds of the content area, not the whole column. */} + {composer ?
{composer}
: } + + {/* plugin homepage sections render last, below everything */} + + + + Your section + + + {["Release 1.4", "Bug triage", "Design QA"].map((card) => ( + + {card} + + + + ))} + + +
+
+ + {/* right panel: no Info/Diff pins here; the new-tab launcher */} +
+ + Actions + + + + Open browser + + + + Start terminal + + + + Your action + +
+
+
+ ); +} + +/* ── the plugin settings page (PluginSettings.tsx order) ────────────── */ + +export function SettingsWireframe() { + return ( + + {/* Page chrome: the settings area's own title bar (SettingsView). */} +
+ + Settings +
+ +
+ {/* Header: icon, name, one-line description (PluginSettings.tsx). */} +
+ + + + + + Hello + + + A friendly example plugin. + + +
+ + {/* One "Configuration" heading covers both settings surfaces on the + real page: the recessed panel holds the form bb generates from the + plugin's declared fields, and any settingsSection components render + beneath it. The markers distinguish them. */} +
+ Configuration + + + + + API key + + secret + + + + Stored server-side; never sent to the browser. + + + + •••••••• + + + + + + Case-sensitive search + + + Match capitalisation when looking things up. + + + + + + + + + Save settings + + + + + {/* settingsSection slots render under the generated form. */} + + + + Your section + + + + Connected as @acme-bot + + Test connection + + + + + +
+ + {/* The page's closing section, verbatim from PluginSettings.tsx. */} +
+ Plugin details + + Release, capabilities, and health live on + + its plugin page + + + +
+
+
+ ); +} + +/* ── the plugin's page in Extensions (ToolsView + PluginDetail) ───────── */ + +/** + * The Extensions detail page for one installed plugin. The one pluggable + * thing on it is the health banner: a plugin that reports needs-configuration + * gets a warning bar at the top of the pane (PluginBannerBar, rendered by + * PluginDetailBanners outside the scroll page), above the header and the + * section stack bb builds from the manifest and registrations. + */ +export function ExtensionsPluginPageWireframe() { + return ( + +
+ + Extensions +
+
+ {/* Banner: full pane width, recessed, with a rule under it; the + icon/title/detail row lines up with the page gutter below. */} + + + + + Needs configuration + + + Set an API key in Settings. Reloads when you save. + + + + Reload + + + +
+ {/* Header: icon, name, publisher badge; the enable toggle and menu + at the right (PluginDetail header). */} +
+ + Hello + + BB Official + + + + + + +
+ + ~/.bb/plugins/hello + + +
+ About + + A friendly example plugin. + +
+ +
+ Configuration + + Configure it on + + its Settings page + + + +
+ +
+ Capabilities + + {[ + ["Settings", "API key, Case-sensitive search"], + ["bb hello", "Say hello from the terminal"], + ].map(([name, what]) => ( + + {name} + {what} + + ))} + +
+
+
+
+ ); +} + +/** The stand-in composer for surfaces with no bb behind them (the docs site). */ +function MockHomeComposer() { + return ( + <> +
+

+ Ask anything. @ to mention files, folders, or sections +

+
+
+ + + + + + Fable 5 · High + + + + + + +
+
+
+ + + acme-app + · worktree + + Full Access +
+ + ); +} diff --git a/packages/plugin-api-map/test/api-sync.test.ts b/packages/plugin-api-map/test/api-sync.test.ts new file mode 100644 index 0000000000..d84797bf06 --- /dev/null +++ b/packages/plugin-api-map/test/api-sync.test.ts @@ -0,0 +1,104 @@ +/** + * The Plugin Guide is bb's only plugin API documentation, so nothing else + * catches it going stale. These tests read the SDK's own contract sources and + * fail when the map and the API disagree in either direction: + * + * - a surface naming a symbol the SDK no longer exports (renamed, removed); + * - a registration slot the SDK ships that no surface documents. + * + * Both failures are actionable in the same place: `src/surfaces.ts`. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { SURFACE_GROUPS } from "../src/index"; + +const SDK_SRC = join(import.meta.dirname, "../../plugin-sdk/src"); + +function read(...parts: string[]): string { + return readFileSync(join(SDK_SRC, ...parts), "utf8"); +} + +const APP_CONTRACT = read("app-contract.ts"); +const CONTRACT_SOURCES = [ + APP_CONTRACT, + // The public entry points, so a card can name a component or hook and not + // just the interface behind it. + read("app.ts"), + read("index.ts"), + read("backend-contract.ts"), + read("testing", "index.ts"), + read("testing", "app.tsx"), + read("testing", "fake-plugin-host.ts"), + read("testing", "fake-sdk.ts"), + read("testing", "host.ts"), +].join("\n"); + +/** Every name the SDK contract sources declare as an export. */ +const EXPORTED = new Set( + [ + ...CONTRACT_SOURCES.matchAll( + /^export (?:declare )?(?:abstract )?(?:interface|type|class|function|const|enum) ([A-Za-z_][A-Za-z0-9_]*)/gm, + ), + ].map((match) => match[1]), +); + +const SURFACES = SURFACE_GROUPS.flatMap((group) => group.surfaces); + +describe("surface-to-SDK links", () => { + it("names only symbols the SDK still exports", () => { + const missing: string[] = []; + for (const surface of SURFACES) { + expect(surface.apiSymbols.length, surface.id).toBeGreaterThan(0); + for (const symbol of surface.apiSymbols) { + if (!EXPORTED.has(symbol)) { + missing.push(`${surface.id}: "${symbol}"`); + } + } + } + // A rename that lands here means the card still describes the old API. + expect(missing).toEqual([]); + }); +}); + +/** + * The registration type each `app.slots.*` / `app.composer.*` / + * `app.contentScripts.*` method takes, read straight out of the interface + * bodies so a slot added to the SDK shows up here with no edit. + */ +function registrationTypes(interfaceName: string): Map { + const body = APP_CONTRACT.match( + new RegExp(`export interface ${interfaceName} \\{([\\s\\S]*?)\\n\\}`), + )?.[1]; + if (!body) throw new Error(`${interfaceName} not found in app-contract.ts`); + const found = new Map(); + // Method signatures, on one line or wrapped across several. + for (const match of body.matchAll( + /^ {2}([A-Za-z_][A-Za-z0-9_]*)\(\s*(?:registration:\s*)?([A-Za-z_][A-Za-z0-9_]*)/gm, + )) { + found.set(match[1], match[2]); + } + return found; +} + +describe("registration slot coverage", () => { + it("documents every slot the SDK ships", () => { + const slots = [ + ...registrationTypes("PluginAppSlots"), + ...registrationTypes("PluginAppComposer"), + ...registrationTypes("PluginAppContentScripts"), + ]; + // Sanity-check the parse itself: a regex that silently matched nothing + // would make this test pass for the wrong reason forever. + expect(slots.length).toBeGreaterThanOrEqual(15); + + const documented = new Set(SURFACES.flatMap((s) => s.apiSymbols)); + const uncovered = slots + .filter(([, type]) => !documented.has(type)) + .map(([method, type]) => `app.${method}() takes ${type}`); + // A new slot with no card is a surface plugin authors cannot discover. + expect(uncovered).toEqual([]); + }); +}); diff --git a/packages/plugin-api-map/test/product-map.test.ts b/packages/plugin-api-map/test/product-map.test.ts new file mode 100644 index 0000000000..6d55840faf --- /dev/null +++ b/packages/plugin-api-map/test/product-map.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { panCarets, SURFACE_GROUPS } from "../src/index"; + +const LAST = SURFACE_GROUPS.length - 1; + +describe("panCarets", () => { + it("disables the caret that has nowhere to go", () => { + // Both carets always render; the end of the range just disables its + // caret, so the row's geometry never changes. + expect(panCarets(0, SURFACE_GROUPS.length)).toEqual({ + previous: false, + next: true, + }); + expect(panCarets(LAST, SURFACE_GROUPS.length)).toEqual({ + previous: true, + next: false, + }); + }); + + it("enables both carets everywhere in between", () => { + for (let index = 1; index < LAST; index++) { + expect(panCarets(index, SURFACE_GROUPS.length), `slide ${index}`).toEqual( + { + previous: true, + next: true, + }, + ); + } + }); + + it("disables both carets when there is a single slide", () => { + expect(panCarets(0, 1)).toEqual({ previous: false, next: false }); + }); +}); diff --git a/packages/plugin-api-map/test/surfaces.test.ts b/packages/plugin-api-map/test/surfaces.test.ts new file mode 100644 index 0000000000..9cf01b8faa --- /dev/null +++ b/packages/plugin-api-map/test/surfaces.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { ANATOMY_MANIFEST as anatomy } from "../src/index"; +import { SURFACE_GROUPS, SURFACE_NUMBERS, SURFACES_BY_ID } from "../src/index"; +import { + ANATOMY_RENDERER_KEYS, + APP_SHELL_MARKS, + COMPOSE_MARKS, + COMPOSER_MARKS, + EXTENSIONS_MARKS, + SETTINGS_MARKS, +} from "../src/index"; + +const groupById = new Map(SURFACE_GROUPS.map((group) => [group.id, group])); + +function surfaceIds(groupId: string): string[] { + return (groupById.get(groupId as never)?.surfaces ?? []).map( + (surface) => surface.id, + ); +} + +describe("product-map surfaces", () => { + it("has globally unique surface ids", () => { + const all = SURFACE_GROUPS.flatMap((group) => + group.surfaces.map((surface) => surface.id), + ); + expect(new Set(all).size).toBe(all.length); + expect(SURFACES_BY_ID.size).toBe(all.length); + }); + + it("marks every visual-group surface on its wireframe exactly once", () => { + // One skeleton per carousel slide, so each group's surfaces must all be + // marked on that group's own wireframe. + expect([...APP_SHELL_MARKS].sort()).toEqual(surfaceIds("app-shell").sort()); + expect([...COMPOSER_MARKS].sort()).toEqual(surfaceIds("composer").sort()); + expect([...COMPOSE_MARKS].sort()).toEqual(surfaceIds("home").sort()); + expect([...SETTINGS_MARKS].sort()).toEqual(surfaceIds("settings").sort()); + expect([...EXTENSIONS_MARKS].sort()).toEqual( + surfaceIds("extensions").sort(), + ); + }); + + it("numbers the surfaces a skeleton draws, and only those", () => { + // A numbered surface with no marker would print a number the diagram + // never shows; an unnumbered marked surface renders an empty chip. + for (const group of SURFACE_GROUPS) { + const numbers = group.surfaces.map((surface) => + SURFACE_NUMBERS.get(surface.id), + ); + if (group.id === "headless") { + expect(numbers.every((number) => number === undefined)).toBe(true); + continue; + } + expect(numbers).toEqual(group.surfaces.map((_, index) => index + 1)); + } + }); + + it("renders every anatomy-manifest region and nothing else", () => { + // The skeletons draw these regions by mapping over the manifest, so a + // manifest key without a renderer would silently drop UI, and a stale + // renderer key would be dead code hiding a manifest drift. + for (const area of [ + "appSidebar", + "sidebarFooter", + "messageActionBar", + ] as const) { + expect([...ANATOMY_RENDERER_KEYS[area]].sort()).toEqual( + [...anatomy[area]].sort(), + ); + } + }); + + it("clusters every headless surface into exactly one named section", () => { + // The pixel-less slide renders FROM these sections, so a surface missing + // from them would silently vanish from the map. + const headless = groupById.get("headless" as never); + const sectioned = (headless?.sections ?? []).flatMap( + (section) => section.surfaceIds, + ); + expect([...sectioned].sort()).toEqual(surfaceIds("headless").sort()); + expect(new Set(sectioned).size).toBe(sectioned.length); + }); + + it("keeps the headless group off the wireframes", () => { + const marked = new Set([ + ...APP_SHELL_MARKS, + ...COMPOSER_MARKS, + ...COMPOSE_MARKS, + ...SETTINGS_MARKS, + ...EXTENSIONS_MARKS, + ]); + for (const id of surfaceIds("headless")) { + expect(marked.has(id)).toBe(false); + } + }); +}); + +describe("surface cross-references", () => { + it("points every [label](id) at a real surface", () => { + // An id that no longer exists renders as plain prose — the reference just + // quietly disappears rather than failing, so nothing else would catch it. + const dangling: string[] = []; + for (const group of SURFACE_GROUPS) { + for (const surface of group.surfaces) { + for (const copy of [surface.summary, ...surface.bullets]) { + for (const [, id] of copy.matchAll(/\[[^\]]+\]\(([a-z0-9-]+)\)/g)) { + if (!SURFACES_BY_ID.has(id)) { + dangling.push(`${surface.id}: "${id}"`); + } + if (id === surface.id) { + dangling.push(`${surface.id}: references itself`); + } + } + } + } + } + expect(dangling).toEqual([]); + }); +}); + +describe("surface card copy", () => { + it("follows the lead-then-bullets template", () => { + // Every card reads the same way: one lead sentence that the bullets hang + // off, then the capabilities. A lead that stops mid-thought (or bullets + // that have nothing to hang off) reads as a broken card. + for (const group of SURFACE_GROUPS) { + for (const surface of group.surfaces) { + expect(surface.summary, surface.id).toMatch( + /\. With this, a plugin can:$/, + ); + expect(surface.bullets.length, surface.id).toBeGreaterThanOrEqual(2); + for (const bullet of surface.bullets) { + expect(bullet.trim().length, surface.id).toBeGreaterThan(0); + // The lead-in already says "can"; a bullet that repeats it reads + // "a plugin can: Can register…". Bullets are bare verb phrases. + expect(bullet, `${surface.id}: "${bullet}"`).not.toMatch(/^Can\b/); + } + } + } + }); +}); diff --git a/packages/plugin-api-map/test/used-by.test.ts b/packages/plugin-api-map/test/used-by.test.ts new file mode 100644 index 0000000000..a1cb9e03e9 --- /dev/null +++ b/packages/plugin-api-map/test/used-by.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + scrollUsedBy, + usedByScrollState, + usedByScrollStep, +} from "../src/index"; + +/** A row wide enough for its items: the common case, and it shows no carets. */ +const FITS = { scrollLeft: 0, scrollWidth: 180, clientWidth: 240 }; +/** Fourteen plugin names in a card-width row, scrolled to the start. */ +const AT_START = { scrollLeft: 0, scrollWidth: 900, clientWidth: 240 }; + +describe("usedByScrollState", () => { + it("offers no carets when the items fit", () => { + expect(usedByScrollState(FITS)).toEqual({ + canScrollLeft: false, + canScrollRight: false, + }); + }); + + it("offers no carets for a row that is exactly full, or over by a rounding error", () => { + // Sub-pixel layout maths must not put a caret on a row that reads as + // fitting, because pressing it would move nothing. + expect( + usedByScrollState({ scrollLeft: 0, scrollWidth: 240, clientWidth: 240 }), + ).toEqual({ canScrollLeft: false, canScrollRight: false }); + expect( + usedByScrollState({ + scrollLeft: 0, + scrollWidth: 240.5, + clientWidth: 240, + }), + ).toEqual({ canScrollLeft: false, canScrollRight: false }); + }); + + it("offers only the right caret at the start", () => { + expect(usedByScrollState(AT_START)).toEqual({ + canScrollLeft: false, + canScrollRight: true, + }); + }); + + it("offers both carets in the middle", () => { + expect(usedByScrollState({ ...AT_START, scrollLeft: 300 })).toEqual({ + canScrollLeft: true, + canScrollRight: true, + }); + }); + + it("offers only the left caret at the end", () => { + // scrollWidth - clientWidth = 660: the far extent. + expect(usedByScrollState({ ...AT_START, scrollLeft: 660 })).toEqual({ + canScrollLeft: true, + canScrollRight: false, + }); + // Browsers can report a fractional scrollLeft just shy of the extent. + expect(usedByScrollState({ ...AT_START, scrollLeft: 659.4 })).toEqual({ + canScrollLeft: true, + canScrollRight: false, + }); + }); +}); + +describe("usedByScrollStep", () => { + it("pages by roughly one visible width, keeping an overlap", () => { + expect(usedByScrollStep(240)).toBe(208); + expect(usedByScrollStep(600)).toBe(568); + }); + + it("still advances usefully in a very narrow row", () => { + // Without a floor, a 40px row would page by 8px, or backwards. + expect(usedByScrollStep(40)).toBe(80); + }); +}); + +describe("scrollUsedBy", () => { + it("scrolls the viewport one page in the pressed direction", () => { + const scrollBy = vi.fn(); + scrollUsedBy({ clientWidth: 240, scrollBy }, 1, { reducedMotion: false }); + expect(scrollBy).toHaveBeenCalledWith({ left: 208, behavior: "smooth" }); + + scrollUsedBy({ clientWidth: 240, scrollBy }, -1, { reducedMotion: false }); + expect(scrollBy).toHaveBeenLastCalledWith({ + left: -208, + behavior: "smooth", + }); + }); + + it("jumps instead of animating when motion is reduced", () => { + const scrollBy = vi.fn(); + scrollUsedBy({ clientWidth: 240, scrollBy }, 1, { reducedMotion: true }); + expect(scrollBy).toHaveBeenCalledWith({ left: 208, behavior: "auto" }); + }); +}); diff --git a/packages/plugin-api-map/test/wireframes.test.ts b/packages/plugin-api-map/test/wireframes.test.ts new file mode 100644 index 0000000000..1e69f02796 --- /dev/null +++ b/packages/plugin-api-map/test/wireframes.test.ts @@ -0,0 +1,76 @@ +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { SURFACE_NUMBERS } from "../src/product-map"; +import { SURFACES_BY_ID } from "../src/surfaces"; +import { + AppShellWireframe, + RealComposerAnnotated, + SurfaceMapContext, + type SurfaceMapState, +} from "../src/wireframes"; + +const mapState: SurfaceMapState = { + activeId: null, + setActiveId: vi.fn(), + expandedId: null, + spotlightId: null, + numberOf: (id) => SURFACE_NUMBERS.get(id) ?? null, +}; + +function renderWireframe(node: ReactNode): string { + return renderToStaticMarkup( + createElement(SurfaceMapContext.Provider, { value: mapState }, node), + ); +} + +describe("guide fixture boundaries", () => { + it("attaches file and code boundaries to their distinct fixtures", () => { + const markup = renderWireframe(createElement(AppShellWireframe)); + const codeRegion = markup.indexOf('data-guide-region="code-renderers"'); + const fileRegion = markup.indexOf('data-guide-region="file-opener"'); + + expect(codeRegion).toBeGreaterThan(-1); + expect(fileRegion).toBeGreaterThan(codeRegion); + expect(markup.slice(codeRegion, fileRegion)).toContain( + 'data-guide-fixture="code-renderer"', + ); + expect(markup.slice(fileRegion)).toContain( + 'data-guide-fixture="file-viewer"', + ); + expect(markup.slice(fileRegion)).toContain("Checkout retry notes"); + }); + + it("attaches the mention annotation to the rendered mention pill", () => { + const markup = renderWireframe( + createElement(RealComposerAnnotated, { + composer: createElement("div", { "data-host-composer": true }), + }), + ); + + expect(markup).toMatch( + /data-guide-region="mention-provider"[\s\S]*@release-notes/, + ); + }); + + it("draws a fixture-owned plugin icon inside the composer action target", () => { + const markup = renderWireframe( + createElement(RealComposerAnnotated, { + composer: createElement("div", { "data-host-composer": true }), + }), + ); + + expect(markup).toMatch( + /data-guide-region="composer-actions"[\s\S]*data-guide-fixture="plugin-composer-action"/, + ); + }); +}); + +describe("guide taxonomy", () => { + it("names the renderer surface for both code and diffs", () => { + expect(SURFACES_BY_ID.get("code-renderers")?.title).toBe( + "Code & diff renderers", + ); + }); +}); diff --git a/packages/plugin-api-map/tsconfig.json b/packages/plugin-api-map/tsconfig.json new file mode 100644 index 0000000000..c67e8af5d3 --- /dev/null +++ b/packages/plugin-api-map/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": ["@bb/tsconfig/base.json"], + "compilerOptions": { + "rootDir": ".", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "resolveJsonModule": true, + "types": ["node", "vitest/globals"], + "composite": false, + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/plugin-api-map/vitest.config.ts b/packages/plugin-api-map/vitest.config.ts new file mode 100644 index 0000000000..c104c88e1f --- /dev/null +++ b/packages/plugin-api-map/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + name: "@bb/plugin-api-map", + include: ["test/**/*.test.ts"], + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/packages/plugin-registry/r/icon-extended.json b/packages/plugin-registry/r/icon-extended.json index 992b777846..d645c2c256 100644 --- a/packages/plugin-registry/r/icon-extended.json +++ b/packages/plugin-registry/r/icon-extended.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/components/ui/icon-extended.tsx", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\n// Extended glyph registry: every named icon the shell does not need before\n// first paint. `./icon` keeps only the core map on the boot path; this module\n// publishes the rest into the registry when it evaluates. Route chunks that\n// render extended icons import it statically (so their icons never flash), and\n// `Icon` loads it on demand for anything else.\n\n// The free hugeicons set ships no artist-palette glyph (its `Palette` export\n// is a pen nib), so this inlines the stroke-rounded palette artwork in the\n// same element format the set uses.\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n NewTab: DashedLine02Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\nimport {\n AiBrowserIcon,\n AiContentGenerator01Icon,\n ArrowDown02Icon,\n ArrowDownDoubleIcon,\n ArrowMoveDownLeftIcon,\n ArrowMoveDownRightIcon,\n ArrowReloadHorizontalIcon,\n ArrowRight02Icon,\n ArrowTurnBackwardIcon,\n ArrowTurnForwardIcon,\n ArrowUp01Icon,\n ArrowUp02Icon,\n ArrowUpDoubleIcon,\n ArrowUpDownIcon,\n ArrowUpRight01Icon,\n AttachmentIcon,\n Book02Icon,\n BrainIcon,\n BrowserIcon,\n Calendar03Icon,\n CalendarCheckOut02Icon,\n ChartColumnIcon,\n CircleArrowShrink01Icon,\n CleanIcon,\n Clock01Icon,\n CloudIcon,\n CloudOffIcon,\n Coffee02Icon,\n CollapseIcon,\n DashedLine02Icon,\n DateTimeIcon,\n DiscordIcon,\n DragDropHorizontalIcon,\n DragDropVerticalIcon,\n Edit04Icon,\n ElectricPlugsIcon,\n ExpandIcon,\n File01Icon,\n FileAttachmentIcon,\n FileEmpty02Icon,\n FileQuestionMarkIcon,\n Folder02Icon,\n FolderEditIcon,\n FolderRemoveIcon,\n GitBranchIcon,\n GitForkIcon,\n GithubIcon,\n GitMergeIcon,\n GitPullRequestArrow,\n GitPullRequestClosedIcon,\n GitPullRequestDraftIcon,\n GitPullRequestIcon,\n GridViewIcon,\n InternetIcon,\n LaptopIcon,\n Layers01Icon,\n LayoutTwoColumnIcon,\n LayoutTwoRowIcon,\n LinkSquare02Icon,\n ListViewIcon,\n LockIcon,\n Mail02Icon,\n MailOpen01Icon,\n Menu02Icon,\n MessageAdd02Icon,\n Mic02Icon,\n PackageReceiveIcon,\n PauseIcon,\n PinIcon,\n PinOffIcon,\n PlayIcon,\n PlusMinusSquare01Icon,\n PlusSignIcon,\n PuzzleIcon,\n Refresh01Icon,\n RepeatIcon,\n SecurityCheckIcon,\n SentIcon,\n SidebarBottomIcon,\n SidebarRightIcon,\n SmartPhone01Icon,\n Sorting01Icon,\n SquareIcon,\n SquareUnlock02Icon,\n StarIcon,\n TestTube01Icon,\n TextWrapIcon,\n TimeScheduleIcon,\n Unarchive03Icon,\n UserIcon,\n ViewIcon,\n ViewOffIcon,\n ZoomInAreaIcon,\n ZoomOutAreaIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { type ExtendedIconMap, registerExtendedIcons } from \"./icon-registry\";\n\n// Extended glyph registry: every named icon the shell does not need before\n// first paint. `./icon` keeps only the core map on the boot path; this module\n// publishes the rest into the registry when it evaluates. Route chunks that\n// render extended icons import it statically (so their icons never flash), and\n// `Icon` loads it on demand for anything else.\n\n// The free hugeicons set ships no artist-palette glyph (its `Palette` export\n// is a pen nib), so this inlines the stroke-rounded palette artwork in the\n// same element format the set uses.\nconst PaletteStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M21.8205 10.4127C22.062 11.8519 22.1827 12.5715 21.2423 13.9326C21.1459 14.0722 20.8966 14.3713 20.777 14.4911C19.6103 15.6586 18.4308 15.6586 16.0716 15.6586H14.1392C13.5085 15.6586 13.1931 15.6586 12.9639 15.7142C11.9586 15.9581 11.3031 16.9391 11.453 17.9755C11.4872 18.2118 11.6043 18.5085 11.8386 19.102C11.9345 19.3449 11.9824 19.4664 12.0136 19.7304C12.1292 20.7084 11.0869 21.9508 10.1158 21.9926C9.85358 22.0039 9.83681 22.0002 9.80326 21.9926C7.66174 21.51 5.66204 20.3123 4.18389 18.4421C0.736789 14.0808 1.43146 7.71364 5.73548 4.22064C10.0395 0.727643 16.323 1.43156 19.7701 5.79289C20.868 7.1819 21.5457 8.77438 21.8205 10.4127Z\",\n fill: \"none\",\n fillRule: \"evenodd\",\n clipRule: \"evenodd\",\n stroke: \"currentColor\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 7.74976H7.24219M7.49219 7.74976C7.49219 7.88783 7.38026 7.99976 7.24219 7.99976C7.10412 7.99976 6.99219 7.88783 6.99219 7.74976C6.99219 7.61169 7.10412 7.49976 7.24219 7.49976C7.38026 7.49976 7.49219 7.61169 7.49219 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M7.36719 15.7498H7.24219M7.49219 15.7498C7.49219 15.8878 7.38026 15.9998 7.24219 15.9998C7.10412 15.9998 6.99219 15.8878 6.99219 15.7498C6.99219 15.6117 7.10412 15.4998 7.24219 15.4998C7.38026 15.4998 7.49219 15.6117 7.49219 15.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M11.8672 5.74976H11.7422M11.9922 5.74976C11.9922 5.88783 11.8803 5.99976 11.7422 5.99976C11.6041 5.99976 11.4922 5.88783 11.4922 5.74976C11.4922 5.61169 11.6041 5.49976 11.7422 5.49976C11.8803 5.49976 11.9922 5.61169 11.9922 5.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n [\n \"path\",\n {\n d: \"M16.3672 7.74976H16.2422M16.4922 7.74976C16.4922 7.88783 16.3803 7.99976 16.2422 7.99976C16.1041 7.99976 15.9922 7.88783 15.9922 7.74976C15.9922 7.61169 16.1041 7.49976 16.2422 7.49976C16.3803 7.49976 16.4922 7.61169 16.4922 7.74976Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"4\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18.3672 11.7498H18.2422M18.4922 11.7498C18.4922 11.8878 18.3803 11.9998 18.2422 11.9998C18.1041 11.9998 17.9922 11.8878 17.9922 11.7498C17.9922 11.6117 18.1041 11.4998 18.2422 11.4998C18.3803 11.4998 18.4922 11.6117 18.4922 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"5\",\n },\n ],\n [\n \"path\",\n {\n d: \"M5.86719 11.7498H5.74219M5.99219 11.7498C5.99219 11.8878 5.88026 11.9998 5.74219 11.9998C5.60412 11.9998 5.49219 11.8878 5.49219 11.7498C5.49219 11.6117 5.60412 11.4998 5.74219 11.4998C5.88026 11.4998 5.99219 11.6117 5.99219 11.7498Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n strokeWidth: \"1.5\",\n key: \"6\",\n },\n ],\n];\n\nexport const EXTENDED_ICON_MAP: ExtendedIconMap = {\n AiBrowser: AiBrowserIcon,\n AiContentGenerator01: AiContentGenerator01Icon,\n AlignLeft: Menu02Icon,\n AppWindow: BrowserIcon,\n ArchiveRestore: Unarchive03Icon,\n ArrowDown: ArrowDown02Icon,\n ArrowRight: ArrowRight02Icon,\n ArrowReloadHorizontal: ArrowReloadHorizontalIcon,\n ArrowUp: ArrowUp02Icon,\n ArrowUpDown: ArrowUpDownIcon,\n ArrowTurnBackward: ArrowTurnBackwardIcon,\n ArrowTurnForward: ArrowTurnForwardIcon,\n ArrowUpRight: ArrowUpRight01Icon,\n Beaker: TestTube01Icon,\n Browser: BrowserIcon,\n Brain: BrainIcon,\n Calendar: Calendar03Icon,\n CalendarCheckOut02: CalendarCheckOut02Icon,\n ChartColumn: ChartColumnIcon,\n ChevronUp: ArrowUp01Icon,\n ChevronsDown: ArrowDownDoubleIcon,\n ChevronsUp: ArrowUpDoubleIcon,\n CircleArrowShrink: CircleArrowShrink01Icon,\n Clean: CleanIcon,\n Clock: Clock01Icon,\n Cloud: CloudIcon,\n CloudOff: CloudOffIcon,\n Coffee: Coffee02Icon,\n Columns2: LayoutTwoColumnIcon,\n CornerDownLeft: ArrowMoveDownLeftIcon,\n CornerDownRight: ArrowMoveDownRightIcon,\n Discord: DiscordIcon,\n DateTime: DateTimeIcon,\n Github: GithubIcon,\n DragDropHorizontal: DragDropHorizontalIcon,\n DragDropVertical: DragDropVerticalIcon,\n EditFile: Edit04Icon,\n ElectricPlugs: ElectricPlugsIcon,\n Eye: ViewIcon,\n EyeOff: ViewOffIcon,\n Explore: Book02Icon,\n ExternalLink: LinkSquare02Icon,\n FileDiff: PlusMinusSquare01Icon,\n File: FileEmpty02Icon,\n FileAttachment: FileAttachmentIcon,\n FileQuestion: FileQuestionMarkIcon,\n FileText: File01Icon,\n FolderOpen: Folder02Icon,\n FolderEdit: FolderEditIcon,\n FolderMinus: FolderRemoveIcon,\n Fork: GitForkIcon,\n GitBranch: GitBranchIcon,\n GitMerge: GitMergeIcon,\n GitPullRequest: GitPullRequestIcon,\n GitPullRequestArrow: GitPullRequestArrow,\n GitPullRequestClosed: GitPullRequestClosedIcon,\n GitPullRequestDraft: GitPullRequestDraftIcon,\n Globe: InternetIcon,\n GridView: GridViewIcon,\n Laptop: LaptopIcon,\n Layers: Layers01Icon,\n ListView: ListViewIcon,\n Lock: LockIcon,\n Mail: Mail02Icon,\n MailOpen: MailOpen01Icon,\n Maximize2: ExpandIcon,\n Mic: Mic02Icon,\n Minimize2: CollapseIcon,\n NewTab: DashedLine02Icon,\n PackageReceive: PackageReceiveIcon,\n Palette: PaletteStrokeRoundedIcon,\n PanelBottom: SidebarBottomIcon,\n PanelRight: SidebarRightIcon,\n Paperclip: AttachmentIcon,\n Pause: PauseIcon,\n Pin: PinIcon,\n PinOff: PinOffIcon,\n Play: PlayIcon,\n Plus: PlusSignIcon,\n Puzzle: PuzzleIcon,\n Repeat: RepeatIcon,\n SecurityCheck: SecurityCheckIcon,\n RotateCcw: Refresh01Icon,\n Rows2: LayoutTwoRowIcon,\n Sent: SentIcon,\n SideChat: MessageAdd02Icon,\n Smartphone: SmartPhone01Icon,\n Sort: Sorting01Icon,\n Square: SquareIcon,\n SquareUnlock02: SquareUnlock02Icon,\n Star: StarIcon,\n TextWrap: TextWrapIcon,\n TimeSchedule: TimeScheduleIcon,\n UserRound: UserIcon,\n ZoomIn: ZoomInAreaIcon,\n ZoomOut: ZoomOutAreaIcon,\n};\n\nregisterExtendedIcons(EXTENDED_ICON_MAP);\n", "type": "registry:ui", "target": "components/ui/icon-extended.tsx" } diff --git a/packages/plugin-registry/r/icon-registry.json b/packages/plugin-registry/r/icon-registry.json index ab7caad2f4..16cfb56230 100644 --- a/packages/plugin-registry/r/icon-registry.json +++ b/packages/plugin-registry/r/icon-registry.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/components/ui/icon-registry.ts", - "content": "import type { IconSvgElement } from \"@hugeicons/react\";\n\n/**\n * Names of the glyphs that live in the lazily loaded extended registry\n * (`./icon-extended`). Only this list of strings is on the boot path; the\n * artwork itself loads with the first route that renders one of these icons\n * or, as a fallback, on first request from `Icon`.\n *\n * `./icon-extended` must map every name here and nothing else; the compiler\n * enforces that through `Record`.\n */\nexport const EXTENDED_ICON_NAMES = [\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DateTime\",\n \"Github\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"NewTab\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\n/**\n * Publishes the extended glyph map. Called by `./icon-extended` when it\n * evaluates, so any chunk that statically imports that module makes every\n * extended icon render synchronously; `Icon` instances that were showing a\n * placeholder re-render through {@link subscribeExtendedIcons}.\n */\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\n/** The extended glyph map, or null until `./icon-extended` has evaluated. */\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\n/** `useSyncExternalStore`-shaped subscription to {@link getExtendedIcons}. */\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n", + "content": "import type { IconSvgElement } from \"@hugeicons/react\";\n\n/**\n * Names of the glyphs that live in the lazily loaded extended registry\n * (`./icon-extended`). Only this list of strings is on the boot path; the\n * artwork itself loads with the first route that renders one of these icons\n * or, as a fallback, on first request from `Icon`.\n *\n * `./icon-extended` must map every name here and nothing else; the compiler\n * enforces that through `Record`.\n */\nexport const EXTENDED_ICON_NAMES = [\n \"AiBrowser\",\n \"AiContentGenerator01\",\n \"AlignLeft\",\n \"AppWindow\",\n \"ArchiveRestore\",\n \"ArrowDown\",\n \"ArrowRight\",\n \"ArrowReloadHorizontal\",\n \"ArrowUp\",\n \"ArrowUpDown\",\n \"ArrowTurnBackward\",\n \"ArrowTurnForward\",\n \"ArrowUpRight\",\n \"Beaker\",\n \"Browser\",\n \"Brain\",\n \"Calendar\",\n \"CalendarCheckOut02\",\n \"ChartColumn\",\n \"ChevronUp\",\n \"ChevronsDown\",\n \"ChevronsUp\",\n \"CircleArrowShrink\",\n \"Clean\",\n \"Clock\",\n \"Cloud\",\n \"CloudOff\",\n \"Coffee\",\n \"Columns2\",\n \"CornerDownLeft\",\n \"CornerDownRight\",\n \"Discord\",\n \"DateTime\",\n \"Github\",\n \"DragDropHorizontal\",\n \"DragDropVertical\",\n \"EditFile\",\n \"ElectricPlugs\",\n \"Eye\",\n \"EyeOff\",\n \"Explore\",\n \"ExternalLink\",\n \"FileDiff\",\n \"File\",\n \"FileAttachment\",\n \"FileQuestion\",\n \"FileText\",\n \"FolderOpen\",\n \"FolderEdit\",\n \"FolderMinus\",\n \"Fork\",\n \"GitBranch\",\n \"GitMerge\",\n \"GitPullRequest\",\n \"GitPullRequestArrow\",\n \"GitPullRequestClosed\",\n \"GitPullRequestDraft\",\n \"Globe\",\n \"GridView\",\n \"Laptop\",\n \"Layers\",\n \"ListView\",\n \"Lock\",\n \"Mail\",\n \"MailOpen\",\n \"Maximize2\",\n \"Mic\",\n \"Minimize2\",\n \"NewTab\",\n \"PackageReceive\",\n \"Palette\",\n \"PanelBottom\",\n \"PanelRight\",\n \"Paperclip\",\n \"Pause\",\n \"Pin\",\n \"PinOff\",\n \"Play\",\n \"Plus\",\n \"Puzzle\",\n \"Repeat\",\n \"RotateCcw\",\n \"Rows2\",\n \"SecurityCheck\",\n \"Sent\",\n \"SideChat\",\n \"Smartphone\",\n \"Sort\",\n \"Square\",\n \"SquareUnlock02\",\n \"Star\",\n \"TextWrap\",\n \"TimeSchedule\",\n \"UserRound\",\n \"ZoomIn\",\n \"ZoomOut\",\n] as const;\n\nexport type ExtendedIconName = (typeof EXTENDED_ICON_NAMES)[number];\n\nexport type ExtendedIconMap = Readonly<\n Record\n>;\n\nlet extendedIcons: ExtendedIconMap | null = null;\nconst listeners = new Set<() => void>();\n\n/**\n * Publishes the extended glyph map. Called by `./icon-extended` when it\n * evaluates, so any chunk that statically imports that module makes every\n * extended icon render synchronously; `Icon` instances that were showing a\n * placeholder re-render through {@link subscribeExtendedIcons}.\n */\nexport function registerExtendedIcons(map: ExtendedIconMap): void {\n if (extendedIcons === map) return;\n extendedIcons = map;\n for (const listener of listeners) listener();\n}\n\n/** The extended glyph map, or null until `./icon-extended` has evaluated. */\nexport function getExtendedIcons(): ExtendedIconMap | null {\n return extendedIcons;\n}\n\n/** `useSyncExternalStore`-shaped subscription to {@link getExtendedIcons}. */\nexport function subscribeExtendedIcons(listener: () => void): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n", "type": "registry:ui", "target": "components/ui/icon-registry.ts" } diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index e7a8f0b5f6..578b9a0846 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.14", + "version": "0.4.15", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index bc8ca1219f..1366e907f0 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1727,6 +1727,14 @@ export interface NewThreadComposerProps { * user typed. */ onSubmit: (request: NewThreadRequest) => void | Promise; + /** + * Which globally registered plugin composer customizations render in this + * embedded composer. `"all"` (the default) preserves the ordinary composed + * experience; `"none"` renders only bb's composer, without plugin banners, + * actions, + menu rows, draft highlighting, or draft observers. + * Experimental: see docs/api_to_audit.md. + */ + experimental_pluginCustomizations?: "all" | "none"; } /** diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index ef80eef3df..c01dcc8366 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -442,6 +442,7 @@ function TestNewThreadComposer({ focusRequest, className, draftKey, + experimental_pluginCustomizations, onSubmit, }: NewThreadComposerProps) { const [text, setText] = useState(initialPrompt ?? ""); @@ -462,6 +463,7 @@ function TestNewThreadComposer({ data-layout={layout} data-focus-request={focusRequest ?? 0} data-draft-key={draftKey ?? ""} + data-plugin-customizations={experimental_pluginCustomizations ?? "all"} className={className} >