Pages, panes, navigation, and commands
A plugin page in bb is a pane kind plus a route. The shell draws the pane wherever the layout puts it, the URL follows the focused pane, and anything that needs a shortcut is a client command. This page teaches how to register a pane kind with typed state, give it a URL, open it from elsewhere, read pane context inside it, wire a command and a keybinding, raise toasts and dialogs, and adapt to the compact layout. The worked plugin is deploy from Guide 7; its deploy pane now gets a URL and a shortcut.
Use this when
- A dashboard page with its own URL. A pane kind plus a route such as
/deploy/:releaseId. - Open your pane next to a thread. A
thread.header.actionsbutton opens it in a new split. - A keyboard shortcut for your command.
deploy.openbound toMod+Shift+Dby default. - A toast when a background job finishes. A query invalidated by the job's wire event drives
useToasts. - Confirm before a destructive action. The shell's
ui-shell/dialogsservice asks the question.
What you build
A pane kind deploy with zod state, a route /deploy/:releaseId, a thread.header.actions button that opens the pane in a new split, a command deploy.open with a default shortcut and a handler, a ReleaseWatcher that toasts when deploy/job.finished arrives, a confirm dialog before a rollback, and a compact variant of the pane.
Steps
1. Know what is built
KernelRoot renders the root slot; ui-shell wins it, renders shell.main, and mounts kernel-ui's LayoutRoot, which mounts every pane of the layout tree with the pane kind's winning component (§4.5). So a page is a pane kind and a route, exactly what hello-slot does with kind hello and path /hello. The status of everything around that, from reference §5.6 and §5.14–5.17:
| Surface | Status |
|---|---|
app.routes.register, app.panes.register, app.tabs.register, useNavigation, usePane |
built (§4.5) |
Pane kinds thread, compose, page (/plugins/:pluginId/:pageId/*, state { pluginId; pageId; subPath }) |
built (§5.15) |
Pane kinds settings, extensions, project-settings, machine, auth-callback |
spec |
ui-shell/pages registry (register({ id; nav; Component; accessory })) |
built, no registrants, no SDK type (§5.14) |
page.header.center, page.header.actions |
spec (§5.6) |
sidebar.nav rows for pages |
built slot, no occupant; page rows are spec (§5.2) |
Every tab kind in §5.16 (thread-info, file, browser, terminal, plugin-page, …) |
spec; none registered in the slice |
pane.dock |
built with ui-shell's PlainDockList fallback; ui-panel-shell is spec (§5.1, §5.7) |
This guide uses the route-plus-pane pattern because it is built end to end. The page pane kind exists, but the registry that would give it shell chrome has no SDK type and nothing occupies its header slots, so register your own kind.
2. Register a pane kind with zod state
PaneKindInput (§4.5): kind, schema, Component, title(state), threadId(state), dock, and optional priority, icon, equals, intents, children. threadId sets the thread scope key for the pane's subtree; return null for a page that is not about one thread. dock: null means no dock.
import { definePluginApp } from "@get-bb/plugin-sdk/app";
import type { PaneProps } from "@get-bb/plugin-sdk/app";
import { z } from "zod";
export const paneState = z.object({ releaseId: z.string().min(1) });
export type PaneState = z.infer<typeof paneState>;
const intentSchema = z.object({ focus: z.enum(["log", "summary"]) });
export function DeployPane(props: PaneProps<PaneState>) {
const intent = props.consumeIntent(); // one-shot; a second call returns null
const focus = intentSchema.safeParse(intent).data?.focus ?? "summary";
return (
<section data-focused={props.isFocused} className="flex flex-col gap-3 p-4">
<h1 className="text-base font-medium">Release {props.state.releaseId}</h1>
<p className="text-sm text-muted-foreground">pane {props.paneId}, showing {focus}</p>
</section>
);
}
export default definePluginApp({
setup(app) {
app.panes.register<PaneState>({
kind: "deploy", schema: paneState, Component: DeployPane,
title: (s) => `Release ${s.releaseId}`, icon: "Zap", threadId: () => null, dock: null,
intents: intentSchema,
});
},
});Several plugins may register one kind; the pane:<kind> pin and priority pick the winner, who receives Original (§4.5). An unregistered kind in a saved layout renders MissingKindPane and the node is kept.
3. Give it a URL
RouteContribution (§4.5): id is <pluginId>/<name>, path takes :name segments and a trailing *, pane.toState(params, url) builds state from the match, pane.toPath(state) does the reverse, title(state) sets document.title. There is no route priority: two plugins on one path is a RouteCollision for the later id.
app.routes.register<PaneState>({ // inside setup(app)
id: "deploy/release",
path: "/deploy/:releaseId",
pane: { kind: "deploy", toState: (params) => ({ releaseId: params["releaseId"] ?? "" }), toPath: (s) => `/deploy/${s.releaseId}` },
title: (s) => `Release ${s.releaseId}`,
});url.hash reaches toState and PaneProps.hash. One-shot payloads travel as intent, never location.state (§5.5, §5.17).
4. Open it from a thread
useNavigation() returns { open, openPath, current, subscribe }. open(target, opts) takes { kind, state } and OpenOptions = { where: "focused" | "new-split" | { paneId }; replace; intent }; the facade fills { where: "focused", replace: false, intent: null } and returns the pane id now showing the target. A state the kind's schema refuses toasts and returns the focused pane id (§4.5). Register the button into thread.header.actions (§5.4, a pane-scoped list; owner props are ThreadOwner = { threadId; projectId; thread }):
import { useNavigation } from "@get-bb/plugin-sdk/app";
import type { SlotComponentProps } from "@get-bb/plugin-sdk/app";
import { Button, Icon } from "@bb/ui";
declare module "@get-bb/plugin-sdk/app" {
interface SlotMap { "thread.header.actions": { kind: "list"; scope: "pane"; props: { threadId: string; projectId: string } } }
}
export function OpenDeployAction(props: SlotComponentProps<"thread.header.actions">) {
const nav = useNavigation();
return (
<Button variant="ghost" size="icon" aria-label="Open release"
onClick={() => nav.open({ kind: "deploy", state: { releaseId: props.threadId } }, { where: "new-split", intent: { focus: "log" } })}>
<Icon name="Zap" className="size-4" aria-hidden />
</Button>
);
}
app.slots.inject("thread.header.actions", (slots) => // inside setup(app)
slots.register({ name: "thread.header.actions", kind: "list", scope: "pane", order: 30 }, OpenDeployAction));openPath("/deploy/rel-42") does the same from a string. nav.open with a target the kind's equals matches focuses the existing pane and delivers the intent there.
5. Read pane context anywhere in the subtree
usePane() returns the same PaneProps the pane component got: paneId, state, isFocused, isMaximized, isOnlyPane, index, hash, dockHost, setState, consumeIntent, dock, renderSlot (§4.5). It throws outside a pane subtree. setState(next) replaces the pane's content in place and the URL with it when focused; an invalid state is refused with a toast, never thrown.
import { usePane } from "@get-bb/plugin-sdk/app";
function ReleasePicker() {
const pane = usePane();
return <Button size="sm" onClick={() => pane.setState({ releaseId: "rel-43" })}>Next release</Button>;
}pane.dock is a DockHandle (toggle, open, close, setMode, addTab(tab, { activate }), removeTab, activate, reorder, setWidth) when the kind declared a dock. app.tabs.register is built, but no tab kind is registered in the slice, so a tab you add renders MissingKindPane unless your plugin also registers the kind (TabKindInput: kind, schema, Component, label, required icon, persist: "thread" | "tab" | (s) => …, §4.5).
6. A command with a shortcut
Client command ids are flat and open (deploy.open); one executor per id, plus before interceptors by priority (§4.6). defaultShortcut is required: a Shortcut object, a variant list, or null for bindable with no default. when names context keys the kernel asserts (modalOpen, editableFocus, layoutCompact, webSurface, desktopSurface, mobileSurface, macPlatform, splitActive) or keys your own components assert with useCommandContext.
app.commands.register({ // inside setup(app)
id: "deploy.open",
title: "Open latest release",
defaultShortcut: { key: "d", mod: true, meta: false, control: false, alt: false, shift: true },
when: { all: [], none: ["modalOpen", "editableFocus"] },
});
app.commands.handle("deploy.open", () => {
window.location.assign("/deploy/latest"); // setup-time executor: no hooks available here
return true;
});Prefer a component-scoped handler when the action needs hooks; useCommandHandler registers on mount and disposes on unmount or when deps change:
import { useCommandHandler, useShortcut } from "@get-bb/plugin-sdk/app";
import { ShortcutHint } from "@bb/ui";
function DeployShortcutHost() {
const nav = useNavigation();
useCommandHandler("deploy.open", () => { nav.open({ kind: "deploy", state: { releaseId: "latest" } }); return true; }, [nav]);
const shortcut = useShortcut("deploy.open"); // the effective binding after user overrides
return shortcut === null ? null : <ShortcutHint shortcut={shortcut} />;
}The first handle is the executor; a second executor is CommandConflict and, as built, fails the later plugin's commit (§4.6). Families (family: { count }) expand deploy.jump.* to .1 … .count and pass index to the handler. User overrides live in the ui-keyboard/keybindings profile preference (§5.18).
7. Toast when a background job finishes
useToasts() returns { show(input): string; dismiss(id) } with ToastInput = { tone; title; description; action } and tone ∈ message | success | warning | error | loading (§4.9). There is no app-side raw event hook; the data path is defineQuery with invalidateOn, so subscribe to the job's wire event through a query and react to the refetched data:
import { defineQuery, useToasts } from "@get-bb/plugin-sdk/app";
import { useEffect, useRef } from "react";
import { jobSchema, releases } from "./contracts.js";
import type { Job } from "./contracts.js";
const latestJob = defineQuery<Record<string, never>, Job>({
key: "latest-job",
fetch: async (_i, client) => jobSchema.parse(await client.query(releases.id, "latestJob", {})),
invalidateOn: [{ name: "deploy/job.finished", key: null }],
});
export function ReleaseWatcher() {
const toasts = useToasts();
const job = latestJob.use({});
const seen = useRef<string | null>(null);
useEffect(() => {
if (job.status !== "success" || job.data.finishedAt === null || seen.current === job.data.id) return;
seen.current = job.data.id;
toasts.show({ tone: job.data.ok ? "success" : "error", title: `Release ${job.data.releaseId} ${job.data.ok ? "deployed" : "failed"}`,
description: job.data.summary, action: { label: "Open", onClick: () => window.location.assign(`/deploy/${job.data.releaseId}`) } });
}, [job, toasts]);
return null;
}Mount ReleaseWatcher from a slot that is always present, such as sidebar.footer with a component that renders it and nothing visible. The server half declares deploy/job.finished with wire: { to: ["client"], key: null } and publishes it when the job settles. defineMutation toasts "<ref>.<method> failed" on error by itself unless silent (§4.8).
8. Confirm with the shell's dialog service
ui-shell/dialogs is a built app service (§5.12). The SDK ships the Dialogs type only; resolve it by id and tolerate its absence the way the threads plugin does:
import { KernelError, useService } from "@get-bb/plugin-sdk/app";
import type { Dialogs } from "@get-bb/plugin-sdk/app";
function useDialogs(): Dialogs | null {
try { return useService<Dialogs>("ui-shell/dialogs"); }
catch (e) { if (e instanceof KernelError && e.code === "service_unavailable") return null; throw e; }
}
function RollbackButton({ releaseId }: { releaseId: string }) {
const dialogs = useDialogs();
const svc = useReleases();
return (
<Button variant="destructive" size="sm" disabled={dialogs === null || svc === null} onClick={async () => {
const ok = await dialogs!.confirm({ title: "Roll back?", description: `Release ${releaseId} will be withdrawn.`, confirmLabel: "Roll back", destructive: true });
if (ok) void svc!.rollback({ releaseId });
}}>Roll back</Button>
);
}dialogs.open(Component, props, { size, dismissible }) returns { result, close } for a custom dialog whose component receives close(result). Kit Dialog and PersistentDrawer acquire modal presence themselves; a raw Radix dialog must call useModalPresence(id, open) so the modalOpen context key and command when clauses see it (§4.6).
9. Compact layout
useLayoutMode() is "compact" | "regular" (bootstrap override → shell hint → (max-width: 767px)), useIsCompact() is the boolean, and usePointerCoarse() is orthogonal (§4.10). For a different tree per mode, register { regular, compact } inside setup(app); the kernel picks by mode and remounts on change:
app.panes.register<PaneState>({ kind: "deploy", schema: paneState, Component: { regular: DeployPane, compact: DeployPaneCompact },
title: (s) => `Release ${s.releaseId}`, threadId: () => null, dock: null });The layoutCompact context key lets a command differ by mode; useShellHost().has("window") gates desktop-only chrome (§4.10, §4.12).
What happens at runtime
setuprecords the pane kind, route, and command in the collector; the build writes them toapp.contributions.jsonwithpanes[] { kind, priority, hasDock },routes[],commands[] { id, defaultShortcut, desktopOnly, menu }(§4.12).- A plugin owning the current route loads in wave 1, so
/deploy/rel-42on a cold start importsdeployfirst (§4.12). - Commit registers preferences, routes, commands, kinds, then slots, atomically; a
RouteCollisionorCommandConflictrolls the whole plugin back (§4.1, §4.6). - The router matches the URL (static segments beat params beat
*), callstoState, andLayoutRootmounts the kind's winning component withPaneProps; an unmatched URL rendersNoRoutePage. - A keydown on
windowruns the first effective binding whose chord matches and whosewhenholds; a consumed run callspreventDefault(§4.6). nav.openreturns the pane id; focusing a different pane with a different URL pushes history,setStatereplaces.
Pitfalls
- Never claim
/: aRouteCollisionfails the later sorted id, anddeploysorts beforeui-compose-page, so the compose page's/route would be the casualty (§5.17; §4.5/D16'sui-shellnaming is the reference's own inconsistency). consumeIntent()is one-shot; read it once and keep the value in state.kindhas no namespace;deployis fine, but a second plugin on the same kind is a candidate, not an error (§4.5).LayoutRootis not exported, so arootoccupant of your own cannot host panes (§4.5).commands.context.defineis not on the SDK face; assert any key string withuseCommandContext(§4.6).- A
defaultShortcutalready taken stays with the earlier plugin by sorted id; readuseShortcutinstead of assuming (§4.6). useToastsbefore mount queues;Dialogshas no provider in a composition withoutui-shell(§4.9).page.header.*and every tab kind are spec; do not register into them and do not expectthread-infoto render (§5.6, §5.16).- Bindings CLI (
bb keybindings …) andui-keyboarditself are named in §5.18 without a built status; rely on theuseShortcutsnapshot.
See also
- Reference §4.5 (routes, panes, tabs), §4.6 (commands), §4.9 (toasts, dialogs), §5.15–5.18.
- Guide 7, UI slots —
childrenon the pane kind, andthread.header.actionsas alistslot. - Guide 9, Timeline rows, interactions, and the composer — opening a thread pane from a row (
nav.open({ kind: "thread", state: { threadId } })). next/examples/plugins/hello-slot/src/app.tsx— thehellokind and/helloroute.