Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3364c28
Add plugin API docs: screen map, reference, and in-app plugin
brsbl Aug 19, 2026
028837e
Carry over pre-existing web edits from the working tree
brsbl Aug 19, 2026
2cdc63e
Plugin Guide v1: in-app plugin API docs with SDK-synced surface map
brsbl Aug 21, 2026
5c6774b
Merge current main into Plugin Guide v1
brsbl Aug 21, 2026
0f67b2d
Merge latest origin/main into Plugin Guide v1
brsbl Aug 21, 2026
d461310
Fix Plugin Guide CI dependencies
brsbl Aug 21, 2026
8887b58
Regenerate plugin icon registry
brsbl Aug 21, 2026
c4f8de1
Merge remote-tracking branch 'origin/main' into bb/plugin-guide-v1-pr…
brsbl Aug 21, 2026
2acb1f1
Refine embedded composer customization policy
brsbl Aug 21, 2026
6febe25
Merge remote-tracking branch 'origin/main' into bb/plugin-guide-v1-pr…
brsbl Aug 21, 2026
b818c56
Merge remote-tracking branch 'origin/main' into bb/plugin-guide-v1-pr…
brsbl Aug 21, 2026
23bc8d4
Lazy-load plugin detail split panes
brsbl Aug 21, 2026
a82011e
Register Plugin Guide as a builtin plugin
brsbl Aug 22, 2026
0f04356
Cover Plugin Guide in builtin category invariant
brsbl Aug 22, 2026
54ea47f
Place code renderers in the thread timeline
brsbl Aug 22, 2026
0323d08
Fix Plugin Guide annotation geometry
brsbl Aug 22, 2026
a3b1519
Merge remote-tracking branch 'origin/main' into bb/plugin-guide-visua…
brsbl Aug 22, 2026
390733b
Document command palette plugin actions
brsbl Aug 22, 2026
d0e2484
Bump plugin SDK for composer isolation policy
brsbl Aug 22, 2026
d92b1c7
Merge current main into Plugin Guide v1
brsbl Aug 24, 2026
85f0e09
Cover Plugin Guide branding SVG in safety tests
brsbl Aug 24, 2026
d62aa88
Make Guide side-panel annotations interactive
brsbl Aug 24, 2026
56a0d2a
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
758dd50
Prevent nested Guide annotation links
brsbl Aug 24, 2026
8c96031
Keep Guide annotations above tab chrome
brsbl Aug 24, 2026
0909f2b
Improve Guide app-window spacing
brsbl Aug 24, 2026
d290857
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
18a3198
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
295615b
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
8999481
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
fb084d2
Add Plugin Guide agent references and maintenance gate
brsbl Aug 24, 2026
27f669d
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 24, 2026
1721534
Give the Plugin Guide app window more room
brsbl Aug 25, 2026
68df60f
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 25, 2026
02045bb
Keep Plugin Guide tab annotations clear
brsbl Aug 25, 2026
bc15642
Order Plugin Guide annotation navigation
brsbl Aug 25, 2026
c0cd102
Clarify app-wide script copy
brsbl Aug 25, 2026
6a2841c
Merge remote-tracking branch 'origin/main' into bb/pr-2248-merge-read…
brsbl Aug 25, 2026
c197ff8
Advance Plugin SDK for Guide APIs
brsbl Aug 25, 2026
c83b326
Refine Plugin Guide surfaces and agent references
brsbl Aug 25, 2026
4fe108b
Frame copied Plugin Guide references
brsbl Aug 25, 2026
e9b2485
Place annotation navigation on card edges
brsbl Aug 25, 2026
79fc9ee
Restore annotation navigation to card header
brsbl Aug 25, 2026
c557816
Demonstrate command palette plugin outcome
brsbl Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 0 additions & 5 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -319,10 +318,6 @@ function AppRoutes() {
path={TOOLS_PLUGIN_BROWSE_ROUTE_PATH}
element={<ExtensionsLandingRedirect />}
/>
<Route
path={TOOLS_PLUGIN_DETAIL_ROUTE_PATH}
element={<ToolsView />}
/>
<Route
path={LEGACY_SKILLS_ROUTE_PATH}
element={<Navigate to={SKILLS_ROUTE_PATH} replace />}
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/components/plugin/PluginSlotMount.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -224,6 +225,7 @@ export function PluginSlotMount({
instanceId,
onCrash,
}: PluginSlotMountProps) {
const onRouteAnchorClick = useRouteAnchorDelegate();
usePluginCss(pluginId);
return (
<PluginContext.Provider value={pluginId}>
Expand All @@ -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}
</div>
Expand Down
253 changes: 253 additions & 0 deletions apps/app/src/components/plugin/docs-anatomy-manifest.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={["/"]}>
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={0}>
<AppCommandProvider>
<QuickCreateProjectProvider>
<ProjectActionsProvider>
<ThreadActionsProvider>
<SidebarProvider>
<AppSidebar
onResizeMouseDown={() => {}}
isResizing={false}
showTopReserve
settingsRoutePath="/settings"
toolsRoutePath="/tools"
/>
</SidebarProvider>
</ThreadActionsProvider>
</ProjectActionsProvider>
</QuickCreateProjectProvider>
</AppCommandProvider>
</TooltipProvider>
</QueryClientProvider>
</JotaiProvider>
</MemoryRouter>,
);
}

describe("docs anatomy manifest", () => {
it("matches AppSidebar's section order", () => {
registerTestPlugin();
const { container } = renderAppSidebar();

const sectionSelectors: Record<string, string> = {
"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<string, () => 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(
<TooltipProvider delayDuration={0}>
<MessageActionBar
messageText="hello"
alignment="start"
mobileActionDisplay="inline"
onAddToChat={() => {}}
onEdit={() => {}}
onFork={() => {}}
onSendToMain={() => {}}
pluginActions={[
{
key: "anatomy-plugin-action",
pluginId: null,
icon: null,
label: "Anatomy message action",
onSelect: () => {},
},
]}
/>
</TooltipProvider>,
);

const actionLabels: Record<string, string> = {
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);
});
});
45 changes: 45 additions & 0 deletions apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
type PromptVoiceConfig,
type TypeaheadConfig,
} from "./PromptBoxInternal";
import { promptMentionClipboardContent } from "./mentions/prompt-mention-clipboard";
import type {
PromptMentionSuggestion,
ProviderCommandSuggestion,
Expand Down Expand Up @@ -3123,6 +3124,50 @@ describe("PromptBoxInternal prompt actions", () => {
expect(getPromptEditorElement().querySelector("blockquote")).not.toBeNull();
});

it("keeps multiple pasted plugin references as distinct pills", async () => {
const { changes, promptBoxRef } = renderPromptBox("");
const reference = (id: string, label: string) => {
const pill = promptMentionClipboardContent({
kind: "plugin",
pluginId: "plugin-api-docs",
icon: null,
itemId: `surface:${id}`,
label,
});
return {
text: `Build a plugin capability like ${pill.text.trimEnd()} using bb's Plugin Guide. `,
html: `Build a plugin capability like ${pill.html.trimEnd()} using bb's Plugin Guide. `,
};
};

await focusPromptEnd(promptBoxRef);
const actions = reference("composer-actions", "Inline actions");
pasteClipboard({ html: actions.html, plainText: actions.text });
await waitFor(() =>
expect(latestChange(changes)?.mentions).toHaveLength(1),
);

const panels = reference("thread-panel", "Thread side-panel tabs");
pasteClipboard({ html: panels.html, plainText: panels.text });

await waitFor(() =>
expect(latestChange(changes)?.mentions).toHaveLength(2),
);
expect(
latestChange(changes)?.mentions.map((mention) => mention.resource),
).toEqual([
expect.objectContaining({ itemId: "surface:composer-actions" }),
expect.objectContaining({ itemId: "surface:thread-panel" }),
]);
expect(
getPromptEditorElement().querySelectorAll(".prompt-mention-pill"),
).toHaveLength(2);
expect(latestValue(changes)).toBe(
"Build a plugin capability like @Inline actions using bb's Plugin Guide. " +
"Build a plugin capability like @Thread side-panel tabs using bb's Plugin Guide. ",
);
});

it("opens the file picker from the prompt actions menu", async () => {
const onAttachFiles = vi.fn();
render(
Expand Down
Loading
Loading