Pages, panels, and commands
The Layout plugin owns the bb app shell. Domain plugins add panel content, status items, and modal content to that shell.
This page adds a Deployments panel and a palette command. The shell owns the route, dock, frame, focus, and compact layout.
Use this when
- Add an app page. Claim
bb.layout.panelsand give the panel navigation data. - Open content in a shell dock. Let the panel controller select the right, bottom, or sidebar dock.
- Add a command to the palette. Claim one key in
bb.commands.commandand callapi.commands.add(). - Add a small global action. Add an item to
bb.layout.statusbar. - Show modal content. Add a declaration to
bb.layout.modalsand let the shell control focus. - Replace a large shell region. Claim
bb.layout.mainorbb.layout.sidebarand use the typedOriginalcomponent.
What you build
You build one panel with the local ID releases. Its local navigation path is deploy.
The shell gives this panel the route /plugins/acme.deploy/deploy.
You also add the command acme.deploy.open. The command appears in the palette and opens the panel route.
Know the shell surfaces
The active bb.app.root winner declares the Layout child surfaces. A replacement root can keep or remove these surface IDs.
| Surface | Kind | Purpose |
|---|---|---|
bb.layout.main |
single |
Owns the main route region. |
bb.layout.sidebar |
single |
Owns the complete navigation column. |
bb.layout.statusbar |
list |
Shows all active status items in a stable order. |
bb.layout.panels |
list |
Stores app-wide panel declarations. |
bb.layout.modals |
list |
Stores modal declarations. |
The main and sidebar surfaces are replaceable. Their global winners receive Original.
The other three surfaces are lists. Each active claim adds one item, so these claims do not enter the winner picker.
1. Declare the claims
Create bb.plugin.jsonc. The manifest lets bb validate the graph before it runs your app artifact.
{
"id": "acme.deploy",
"version": "2.0.0",
"description": "Deployment views and commands.",
"claims": [
{ "surface": "bb.layout.panels" },
{
"service": "bb.commands.command",
"key": "acme.deploy.open"
}
],
"artifacts": {
"app": "./dist/app.js"
}
}The build writes the complete claim data to contract.json. bb validates the manifest and this generated file together.
Contract IDs use dots. A third-party command key starts with the plugin ID.
2. Write the panel component
The shell passes state and one panel controller to the component. The component owns only its body.
// src/DeployPanel.tsx
import type { LayoutPanelProps } from "@bb/layout/contracts";
type DeployPanelState = {
releaseId: string;
};
export function DeployPanel({
instanceId,
dock,
state,
focused,
controller,
}: LayoutPanelProps<DeployPanelState>) {
const releaseId = state?.releaseId ?? "latest";
return (
<section className="flex h-full flex-col gap-3 p-4">
<header>
<h1 className="text-base font-medium">Deployments</h1>
<p className="text-sm text-muted-foreground">
Release {releaseId} is in the {dock} dock.
</p>
</header>
<button
type="button"
className="w-fit rounded border px-3 py-2"
disabled={!focused}
onClick={() =>
controller.updateState(instanceId, { releaseId: "rel-43" })
}
>
Show rel-43
</button>
</section>
);
}Use JSON values for panel state. The shell can store that state when the declaration sets persistent: true.
Do not create a dock, tab strip, resize control, or title bar. The Layout winner owns those parts.
3. Add the panel declaration
Use api.surfaces.add() because bb.layout.panels is a list surface.
// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";
import { DeployPanel } from "./DeployPanel.js";
export default definePlugin((api) => {
api.surfaces.add("bb.layout.panels", {
id: "releases",
title: "Deployments",
icon: "Rocket",
component: DeployPanel,
preferredDock: "right",
allowedDocks: ["right", "bottom"],
frame: "flush",
instances: "single",
persistent: true,
navigation: {
path: "deploy",
order: 40,
},
});
api.commands.add({
id: "acme.deploy.open",
title: "Deployments: open panel",
description: "Open the app-wide deployment panel.",
category: "Deployments",
discoverability: {
searchable: true,
keywords: ["release", "ship", "panel"],
order: 40,
},
defaultKeybindings: [
{
chord: { key: "d", mod: true, shift: true },
platform: "desktop",
when: { none: ["modalOpen"] },
},
],
run() {
window.location.assign("/plugins/acme.deploy/deploy");
},
});
});The shell adds the plugin ID to the local panel ID. This rule prevents a silent panel collision.
The navigation path opens the single panel instance. A later open request focuses the same instance.
The shell can move the panel when the viewport becomes compact. The panel cannot set pixel sizes.
4. Understand the palette command
bb.commands.command is a replaceable keyed service contract. The key is acme.deploy.open in this example.
The command winner owns its title, search data, availability test, keybindings, and executor. The palette reads this same catalog.
Menus, keybindings, direct calls, and the palette all run the same command. They also use the same interception chain.
The discoverability.searchable field puts the command in the palette. Use a clear title because the palette searches the title.
The user can change the keybinding. The override stays with the command key after a winner change.
If two default keybindings conflict, bb activates neither default. The user must select a keybinding.
5. Open panels from shell content
Components inside the Layout tree can use the nearest panel controller.
import { usePanelController } from "@bb/layout/hooks";
export function OpenDeploymentsButton() {
const panels = usePanelController();
return (
<button
type="button"
onClick={() =>
panels.open("acme.deploy/releases", {
state: { releaseId: "rel-42" },
dock: "right",
focus: true,
})
}
>
Open deployments
</button>
);
}The full panel ID uses the claimant ID and the local registration ID. This runtime entry ID is not a contract ID.
A multiple panel uses instanceKey as its stable instance identity. The controller creates a key when you omit one.
You can also declare a target schema. The shell rejects an invalid target before it changes panel state.
6. Use the other Layout surfaces
Use bb.layout.statusbar for a small global item. The shell supplies route facts plus panel and modal controllers.
Use bb.layout.modals for modal content. The shell owns the stack, backdrop, focus, and focus return.
Use bb.layout.main or bb.layout.sidebar only when you must replace a complete shell region.
api.surfaces.provide(
"bb.layout.sidebar",
({ props, Original }) => (
<div data-owner="acme.deploy">
<Original {...props} />
</div>
),
);Add the matching static claim before you provide this surface. The claim joins the global winner picker.
Keep the established child surface IDs when other plugins must continue to use them. A replacement can remove a child surface deliberately.
What happens at runtime
- The loader reads
bb.plugin.jsoncandcontract.json. - The loader checks both claims before it imports the app artifact.
- The factory stages the panel and command in one transaction.
- The loader commits both registrations after the factory succeeds.
- The Layout shell adds the panel to its catalog and navigation.
- The Commands UI reads the active command winner and adds its searchable row.
- Plugin unload removes both registrations through automatic cleanup.
A factory error leaves no partial panel or command. A failed replacement candidate does not change the active generation.
Pitfalls
- A panel declaration does not open the panel. Add navigation data or call the controller.
- Panel state must contain JSON values. Do not store a component or service handle in it.
- The shell stores persistent state. It never stores a session target.
- A panel cannot own its dock frame or modal focus rules.
- A
singlesurface replacement must use the exact typed props contract. - A command key must use dots and the owner namespace.
- A command claim and its
api.commands.add()ID must match. - The palette shows only the active winner for each command key.
See also
- Guide 7 explains list, single, keyed, and chain surfaces.
- Guide 9 explains thread view, side-panel, timeline, and composer surfaces.
- The
bb.layoutdesign page defines the shell contracts. - The
bb.commandsdesign page defines commands, keybindings, and interception.