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.actions button opens it in a new split.
  • A keyboard shortcut for your command. deploy.open bound to Mod+Shift+D by 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/dialogs service 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

  1. setup records the pane kind, route, and command in the collector; the build writes them to app.contributions.json with panes[] { kind, priority, hasDock }, routes[], commands[] { id, defaultShortcut, desktopOnly, menu } (§4.12).
  2. A plugin owning the current route loads in wave 1, so /deploy/rel-42 on a cold start imports deploy first (§4.12).
  3. Commit registers preferences, routes, commands, kinds, then slots, atomically; a RouteCollision or CommandConflict rolls the whole plugin back (§4.1, §4.6).
  4. The router matches the URL (static segments beat params beat *), calls toState, and LayoutRoot mounts the kind's winning component with PaneProps; an unmatched URL renders NoRoutePage.
  5. A keydown on window runs the first effective binding whose chord matches and whose when holds; a consumed run calls preventDefault (§4.6).
  6. nav.open returns the pane id; focusing a different pane with a different URL pushes history, setState replaces.

Pitfalls

  • Never claim /: a RouteCollision fails the later sorted id, and deploy sorts before ui-compose-page, so the compose page's / route would be the casualty (§5.17; §4.5/D16's ui-shell naming is the reference's own inconsistency).
  • consumeIntent() is one-shot; read it once and keep the value in state.
  • kind has no namespace; deploy is fine, but a second plugin on the same kind is a candidate, not an error (§4.5).
  • LayoutRoot is not exported, so a root occupant of your own cannot host panes (§4.5).
  • commands.context.define is not on the SDK face; assert any key string with useCommandContext (§4.6).
  • A defaultShortcut already taken stays with the earlier plugin by sorted id; read useShortcut instead of assuming (§4.6).
  • useToasts before mount queues; Dialogs has no provider in a composition without ui-shell (§4.9).
  • page.header.* and every tab kind are spec; do not register into them and do not expect thread-info to render (§5.6, §5.16).
  • Bindings CLI (bb keybindings …) and ui-keyboard itself are named in §5.18 without a built status; rely on the useShortcut snapshot.

See also

  • Reference §4.5 (routes, panes, tabs), §4.6 (commands), §4.9 (toasts, dialogs), §5.15–5.18.
  • Guide 7, UI slots — children on the pane kind, and thread.header.actions as a list slot.
  • 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 — the hello kind and /hello route.