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.itemsurface. - Replace or wrap the composer. Claim the single
bb.thread-ui.composersurface. - Add a composer control or card. Claim the list
bb.thread-ui.composer.actionssurface. - Check or change a send. Claim the chain
bb.thread-ui.composer.sendsurface. - Add a panel beside a thread. Claim the view-declared
bb.thread-ui.sidePanelssurface. - Keep a draft through a UI change. Store it through the
bb.threadsservice.
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.itemThe 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 buildThe 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
- The loader reads the manifest and generated contract before it runs plugin code.
- The kernel adds the composer claim to the global winner picker.
- The kernel activates list and chain claims under their live parent surfaces.
- The timeline owner selects one winner for the exact item type.
- The composer freezes one send chain and calls each member in order.
- The
bb.threadsservice 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
ComposerPropsandbb.threads.drafts. - Do not call
next()more than once. The host freezes one chain for each send. - Do not expect
Originalon list, keyed, or chain surfaces. Asinglesurface winner receives it. - Do not put React components in
TimelineItem.payload. The headless service stores JSON data only. - Do not declare
bb.thread-ui.sidePanelsat the plugin root. The active view implementation declares it. - Do not use a slash in a contract ID. The slash in
acme.review/reviewbelongs 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.