Thread timeline, composer, and side panels

bb.thread-ui presents thread data from the headless bb.threads service. A plugin can render one timeline item type. It can also wrap the composer, add composer actions, check sends, and add a thread side panel.

This page builds an app plugin named acme.review. The plugin adds a review item, a composer action, a send check, and a review panel. It also wraps the standard composer without taking ownership of draft data.

Use this when

  • Render one timeline item type. Claim the keyed bb.thread-ui.timeline.item surface.
  • Replace or wrap the composer. Claim the single bb.thread-ui.composer surface.
  • Add a composer control or card. Claim the list bb.thread-ui.composer.actions surface.
  • Check or change a send. Claim the chain bb.thread-ui.composer.send surface.
  • Add a panel beside a thread. Claim the view-declared bb.thread-ui.sidePanels surface.
  • Keep a draft through a UI change. Store it through the bb.threads service.

The presentation contracts

The four surface kinds give each extension point a clear conflict rule.

Surface Kind Result
bb.thread-ui.composer single One global winner renders each composer. The winner receives Original.
bb.thread-ui.composer.actions list All active contributions appear in a stable order.
bb.thread-ui.composer.send chain Each active member can pass, change, or block one send.
bb.thread-ui.timeline.item keyed One global winner renders each exact item type.
bb.thread-ui.sidePanels list All active panel launchers appear in a stable order.

The surface IDs use dots. The value acme.review/review below is a timeline item type, not a contract ID.

Thread surfaces form a hierarchy.

bb.layout.main
└─ bb.thread-ui main implementation
   ├─ bb.thread-ui.view
   │  └─ bb.thread-ui.sidePanels
   ├─ bb.thread-ui.composer
   ├─ bb.thread-ui.composer.actions
   ├─ bb.thread-ui.composer.send
   └─ bb.thread-ui.timeline.item

The active thread view declares bb.thread-ui.sidePanels. A replacement view can declare that child surface or omit it. The kernel activates panel claims only while the child declaration exists.

Declare the claims

Create bb.plugin.jsonc at the package root.

{
  "id": "acme.review",
  "version": "2.0.0",
  "description": "Review controls for bb threads.",
  "claims": [
    { "surface": "bb.thread-ui.composer" },
    { "surface": "bb.thread-ui.composer.actions" },
    { "surface": "bb.thread-ui.composer.send" },
    {
      "surface": "bb.thread-ui.timeline.item",
      "key": "acme.review/review"
    },
    { "surface": "bb.thread-ui.sidePanels" }
  ],
  "requires": [
    { "service": "bb.threads", "range": "^1" }
  ],
  "artifacts": {
    "app": "./dist/app.js",
    "server": "./dist/server.js"
  }
}

The bb.thread-ui.composer claim joins the global winner picker. Installation does not replace the current winner without a user choice.

The timeline claim joins the picker only for acme.review/review. Other item types keep their current winners.

The list claims do not conflict. The composer send members form a visible and reorderable chain.

Render one timeline item type

bb.threads supplies one canonical TimelineItem. The item contains data only. bb.thread-ui adds presentation values and view state.

The keyed surface uses the exact item.type as its key. Core item types include message, tool, plan, and interaction. An extension type uses <ownerId>/<name>.

Add this code to src/app.tsx.

import { definePlugin } from "@get-bb/plugin/app";
import { Button } from "@bb/ui";
import type {
  ComposerActionContext,
  ComposerSendMiddleware,
  ThreadSidePanelProps,
  TimelineItemProps,
} from "@bb/thread-ui/contracts";

type ReviewPayload = {
  title: string;
  summary: string;
};

function readReviewPayload(value: unknown): ReviewPayload | null {
  if (value === null || typeof value !== "object") return null;
  const data = value as Record<string, unknown>;
  if (typeof data.title !== "string") return null;
  if (typeof data.summary !== "string") return null;
  return { title: data.title, summary: data.summary };
}

function ReviewItem(props: TimelineItemProps) {
  const review = readReviewPayload(props.item.payload);

  if (review === null) {
    return <p role="alert">This review item has invalid data.</p>;
  }

  const label = props.item.status === "pending"
    ? props.presentation.label.pending
    : props.presentation.label.completed;

  return (
    <article>
      <p>{label}</p>
      <h3>{review.title}</h3>
      <p>{review.summary}</p>
      <Button
        size="sm"
        onClick={() => props.openPanel("acme.review.panel", {
          itemId: props.item.id,
        })}
      >
        Open review
      </Button>
      {props.children}
    </article>
  );
}

The renderer receives the thread, provider ID, expansion state, nested children, and message actions. It does not load thread records itself.

The first-party plugin owns * as the fallback key for an unknown extension type. That fallback applies when no plugin claims the exact key.

Wrap the composer

bb.thread-ui.composer is replaceable and single. Its active winner receives the typed Original component.

Use Original when you want to preserve the standard editor and add small UI around it.

api.surfaces.provide(
  "bb.thread-ui.composer",
  ({ Original, props }) => (
    <section aria-label="Review composer">
      <p>Review checks will run before this message sends.</p>
      <Original {...props} />
    </section>
  ),
);

The standard composer receives a ComposerProps object. Important fields include draft, selection, runState, and submitMode. Its actions include setDraft(), setSelection(), and send().

A full replacement must use props.draft as its draft source. It must call props.setDraft() after each edit. Local component state must not become the durable draft source.

Add a composer action

bb.thread-ui.composer.actions is a list. Each contribution has one stable ID, one order, and one placement.

This action puts a button in the action row. It adds a review request to the current draft.

function RequestReviewAction(context: ComposerActionContext) {
  return (
    <Button
      size="sm"
      onClick={() => context.composer.updateText((text) =>
        text.startsWith("/review ") ? text : `/review ${text}`
      )}
    >
      Request review
    </Button>
  );
}

The same list also supports plus-menu actions and stack cards. A stack contribution can select card or bare chrome.

Use ComposerHandle for local editor behavior. It can update text, insert a mention, add a quote, clear content, or focus the editor.

Check the send chain

bb.thread-ui.composer.send wraps the final send. Each member receives the draft, selection, scope, intent, and abort signal.

A member can return a block result. It can also call next() once to continue the frozen chain.

const requireReviewSummary: ComposerSendMiddleware = {
  id: "acme.review.require-summary",
  order: 550,
  when: ({ draft }) =>
    draft.text.startsWith("/review"),
  async run(context, next) {
    if (context.draft.text.trim() === "/review") {
      return {
        status: "blocked" as const,
        code: "acme.review.summary-required",
        reason: "Add a review summary.",
      };
    }

    return next();
  },
};

The ComposerSendMiddleware type checks the context, the block result, and the value from next().

A middleware can call next(changedContext) to change a request. It must also correct mention ranges when it changes draft text.

Add a side panel

The default thread view declares the bb.thread-ui.sidePanels child surface. Each list contribution supplies one launcher and one body.

function ReviewPanel(props: ThreadSidePanelProps) {
  const target = props.context.kind === "thread"
    ? `Thread ${props.context.threadId}`
    : "A new thread";

  return (
    <section>
      <h2>Review</h2>
      <p>{target}</p>
      <Button size="sm" onClick={props.close}>Close</Button>
    </section>
  );
}

The layout plugin owns the frame. The contribution owns only its title, launcher, body, and panel state.

The openPanel() call in ReviewItem opens this contribution. It passes JSON data through params in ThreadSidePanelProps.

Register all app behavior

Complete src/app.tsx with one factory.

export default definePlugin((api) => {
  api.surfaces.provide(
    "bb.thread-ui.composer",
    ({ Original, props }) => (
      <section aria-label="Review composer">
        <p>Review checks will run before this message sends.</p>
        <Original {...props} />
      </section>
    ),
  );

  api.surfaces.provide("bb.thread-ui.composer.actions", {
    id: "acme.review.request",
    order: 70,
    placement: "action-row",
    title: "Request review",
    component: RequestReviewAction,
  });

  api.surfaces.provide(
    "bb.thread-ui.composer.send",
    requireReviewSummary,
  );

  api.surfaces.provide("bb.thread-ui.timeline.item", {
    key: "acme.review/review",
    component: ReviewItem,
  });

  api.surfaces.provide("bb.thread-ui.sidePanels", {
    id: "acme.review.panel",
    title: "Review",
    order: 40,
    placement: "thread",
    component: ReviewPanel,
  });
});

The factory receives one typed api object. The runtime releases all surface registrations when the plugin unloads.

Keep drafts in bb.threads

The composer surface does not own draft storage. The bb.threads service stores drafts by ComposerScope.

Its draft interface has four operations.

export interface ThreadDraftsService {
  get(args: { scope: ComposerScope }): Promise<ComposerDraft | null>;
  set(args: {
    scope: ComposerScope;
    draft: ComposerDraft;
    expectedRevision?: number;
  }): Promise<ComposerDraft>;
  clear(args: { scope: ComposerScope }): Promise<void>;
  subscribe(
    args: { scope: ComposerScope },
    listener: (draft: ComposerDraft | null) => void,
  ): ThreadSubscription;
}

The default composer reads this service and passes the current value through ComposerProps. A winner change remounts the UI, but it does not remove the stored draft.

A server artifact can read the same durable draft.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { threadsService } from "@bb/threads/contracts";

export default defineServerPlugin(async (api) => {
  const threads = await api.services.use(threadsService);

  const subscription = threads.events.subscribe(
    "thread.idle",
    async ({ payload }) => {
      const scope = {
        kind: "thread",
        threadId: payload.thread.id,
      } as const;
      const draft = await threads.drafts.get({ scope });

      api.log.info("Review draft state.", {
        threadId: payload.thread.id,
        hasDraft: draft !== null,
      });
    },
  );

  return () => subscription.close();
});

The required service edge controls start order. A bb.threads winner change restarts this server artifact with a fresh handle.

Build and inspect the contract

Run the standard build.

bb plugin build

The build validates bb.plugin.jsonc. It generates contract.json beside the built manifest. The generated file records the claims, keyed values, service edges, and stability data.

Do not edit contract.json by hand. The loader validates both files as one unit before it runs the factories.

What happens at runtime

  1. The loader reads the manifest and generated contract before it runs plugin code.
  2. The kernel adds the composer claim to the global winner picker.
  3. The kernel activates list and chain claims under their live parent surfaces.
  4. The timeline owner selects one winner for the exact item type.
  5. The composer freezes one send chain and calls each member in order.
  6. The bb.threads service keeps drafts and timeline data outside the React tree.

If the composer winner fails, the surface boundary returns to the default implementation. If the thread view omits sidePanels, the panel claim becomes inactive.

Pitfalls

  • Do not store the durable draft only in React state. Use ComposerProps and bb.threads.drafts.
  • Do not call next() more than once. The host freezes one chain for each send.
  • Do not expect Original on list, keyed, or chain surfaces. A single surface winner receives it.
  • Do not put React components in TimelineItem.payload. The headless service stores JSON data only.
  • Do not declare bb.thread-ui.sidePanels at the plugin root. The active view implementation declares it.
  • Do not use a slash in a contract ID. The slash in acme.review/review belongs to the item type key.
  • Do not assume a new composer claim becomes the winner. The user chooses through the global winner picker.

See also

  • Thread UI design defines all presentation contracts.
  • Threads design defines records, timeline data, execution, drafts, and selection.
  • Guide 7 explains the four surface kinds and Original.