From 8854feb1294fec71127773131fcfb22aec326217 Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:39:45 -0700
Subject: [PATCH 01/13] Report anonymous automation-created and
split-view-opened events
Adds automation_created (trigger, mode, origin) and split_view_opened
(pane count after opening, 2-8) to the client telemetry allow-list.
Automations emits from its server create path through the plugin SDK's
system.experimental_recordTelemetryEvent, so the RPC and CLI paths report
once each and failures never affect creation. The app reports split view
from one watcher on the split layout, so every surface that adds a pane
reports it and restoring a saved layout does not. The server still drops
both when usage data sharing is off. Privacy copy, configuration docs, the
API audit entry, and the Automations overview list the new events.
Plugin SDK 0.6.40.
Co-Authored-By: Claude Opus 5.5
---
apps/app/src/components/layout/AppLayout.tsx | 2 +
.../useSplitViewTelemetry.test.tsx | 59 +++++++++++++++++++
.../lib/split-layout/useSplitViewTelemetry.ts | 17 ++++++
.../test/system/telemetry-events.test.ts | 29 +++++++++
apps/web/src/routes/privacy.tsx | 7 ++-
docs/api_to_audit.md | 2 +-
docs/configuration.md | 9 ++-
packages/domain/src/plugin-sdk-version.ts | 2 +-
packages/plugin-sdk/package.json | 2 +-
packages/server-contract/src/api/system.ts | 20 +++++++
plugins/automations/PLUGIN_OVERVIEW.md | 4 ++
plugins/automations/package.json | 2 +-
.../automations/src/server-harness.test.ts | 15 ++++-
plugins/automations/src/service.ts | 6 ++
plugins/automations/src/telemetry.ts | 18 ++++++
15 files changed, 185 insertions(+), 9 deletions(-)
create mode 100644 apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
create mode 100644 apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
create mode 100644 plugins/automations/src/telemetry.ts
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index a2a8c547d9b..e9caa0099bc 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -116,6 +116,7 @@ import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight";
import { isIOSWebKit } from "@/lib/ios-webkit";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import { useSplitViewTelemetry } from "@/lib/split-layout/useSplitViewTelemetry";
import { findPaneByThread } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";
@@ -527,6 +528,7 @@ export function AppLayout({ children }: AppLayoutProps) {
useServerDaemonLogsCommand();
useDataDirectoryCommand();
useWindowReloadCommand();
+ useSplitViewTelemetry();
usePluginSafeModeCommands();
usePluginCachePruneCommand();
const archivedSectionId = isArchivedView
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
new file mode 100644
index 00000000000..b644a92b0b3
--- /dev/null
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { Provider, createStore } from "jotai";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { splitLayoutAtom } from "./atoms";
+import { removePane, splitPane } from "./ops";
+import type { SplitLayout } from "./types";
+import { useSplitViewTelemetry } from "./useSplitViewTelemetry";
+
+const mocks = vi.hoisted(() => ({ recordTelemetryEvent: vi.fn() }));
+
+vi.mock("@/components/onboarding/onboarding-telemetry", () => ({
+ recordTelemetryEvent: mocks.recordTelemetryEvent,
+}));
+
+afterEach(() => {
+ cleanup();
+ mocks.recordTelemetryEvent.mockReset();
+ window.sessionStorage.clear();
+ window.localStorage.clear();
+});
+
+const thread = (threadId: string) => ({ kind: "thread" as const, projectId: "project-1", threadId });
+
+function singlePane(): SplitLayout {
+ return { root: { type: "pane", paneId: "pane-1", content: thread("thread-1") }, focusedPaneId: "pane-1" };
+}
+
+function mount(initial: SplitLayout | null) {
+ const store = createStore();
+ store.set(splitLayoutAtom, initial);
+ const wrapper = ({ children }: { children: ReactNode }) => {children};
+ renderHook(() => useSplitViewTelemetry(), { wrapper });
+ const update = (next: (layout: SplitLayout) => SplitLayout) => act(() => {
+ store.set(splitLayoutAtom, next(store.get(splitLayoutAtom) ?? singlePane()));
+ });
+ return { update };
+}
+
+describe("useSplitViewTelemetry", () => {
+ it("reports each added pane with the pane count, and nothing for closing panes", () => {
+ const { update } = mount(singlePane());
+ update((layout) => splitPane(layout, "pane-1", "right", thread("thread-2")));
+ update((layout) => splitPane(layout, layout.focusedPaneId, "bottom", thread("thread-3")));
+ update((layout) => removePane(layout, layout.focusedPaneId));
+ expect(mocks.recordTelemetryEvent.mock.calls).toEqual([
+ [{ name: "split_view_opened", properties: { panes: 2 } }],
+ [{ name: "split_view_opened", properties: { panes: 3 } }],
+ ]);
+ });
+
+ it("does not report a split layout restored when the app opens", () => {
+ const restored = splitPane(singlePane(), "pane-1", "right", thread("thread-2"));
+ mount(restored);
+ expect(mocks.recordTelemetryEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
new file mode 100644
index 00000000000..bfa8d63021c
--- /dev/null
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
@@ -0,0 +1,17 @@
+import { useAtomValue } from "jotai";
+import { useEffect, useRef } from "react";
+import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
+import { splitLayoutAtom } from "./atoms";
+import { countPanes } from "./ops";
+
+export function useSplitViewTelemetry(): void {
+ const layout = useAtomValue(splitLayoutAtom);
+ const panes = layout === null ? 1 : countPanes(layout.root);
+ const previousPanes = useRef(panes);
+ useEffect(() => {
+ if (panes > previousPanes.current && panes >= 2) {
+ recordTelemetryEvent({ name: "split_view_opened", properties: { panes } });
+ }
+ previousPanes.current = panes;
+ }, [panes]);
+}
diff --git a/apps/server/test/system/telemetry-events.test.ts b/apps/server/test/system/telemetry-events.test.ts
index 59d50d07420..11aab1f041a 100644
--- a/apps/server/test/system/telemetry-events.test.ts
+++ b/apps/server/test/system/telemetry-events.test.ts
@@ -102,4 +102,33 @@ describe("client telemetry events", () => {
expect(capture.mock.calls).toEqual([[shown], [used]]);
});
});
+ it("accepts automation and split view events and rejects values outside their enums or pane range", async () => {
+ await withTestHarness(async (harness) => {
+ const capture = vi.fn();
+ harness.deps.telemetry = { ...harness.deps.telemetry, capture };
+ const automation = {
+ name: "automation_created",
+ properties: { trigger: "schedule", mode: "script", origin: "agent" },
+ };
+ const split = { name: "split_view_opened", properties: { panes: 2 } };
+
+ const accepted = [await post(harness, automation), await post(harness, split)];
+ const rejected = [
+ await post(harness, {
+ name: "automation_created",
+ properties: { ...automation.properties, cron: "0 9 * * *" },
+ }),
+ await post(harness, {
+ name: "automation_created",
+ properties: { ...automation.properties, trigger: "interval" },
+ }),
+ await post(harness, { name: "split_view_opened", properties: { panes: 1 } }),
+ await post(harness, { name: "split_view_opened", properties: { panes: 9 } }),
+ ];
+
+ expect(accepted.map((response) => response.status)).toEqual([200, 200]);
+ expect(rejected.map((response) => response.status)).toEqual([400, 400, 400, 400]);
+ expect(capture.mock.calls).toEqual([[automation], [split]]);
+ });
+ });
});
diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx
index 6c613beda24..643405f9a6c 100644
--- a/apps/web/src/routes/privacy.tsx
+++ b/apps/web/src/routes/privacy.tsx
@@ -73,8 +73,11 @@ function PrivacyRoute() {
Unless you turn it off, the bb server sends us anonymous usage
events through PostHog: app starts, thread and message counts,
plugin installs, setup progress, such as which first-run steps you
- completed or skipped and whether you turned on notifications, and
- which built-in tips were shown or used. Each event carries a random
+ completed or skipped and whether you turned on notifications,
+ which built-in tips were shown or used, when you create an
+ automation (whether it repeats or runs once, and whether it runs an
+ agent or a script), and when you open a split view (how many panes
+ it has). Each event carries a random
install ID, the app version, and your operating system and
architecture. Events never include your prompts, code, file names,
project names, or account details. Turn this off in Settings →
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 3fc7d6bfbee..d0b8880e929 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -4044,7 +4044,7 @@ Stabilization requires exercising reconnect/reload, concurrent deduplication, ro
## `PluginBbSdk.system.experimental_recordTelemetryEvent`
-`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide and notification-card events with fixed enum or boolean properties; anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`) and the sidebar notification card (`SidebarNotificationsPrompt` and `apps/app/src/hooks/useNotificationPermission.ts`). Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry.
+`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide, notification-card, tip, automation-created, and split-view events with fixed enum, boolean, or small-count properties; anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`), the sidebar notification card (`SidebarNotificationsPrompt` and `apps/app/src/hooks/useNotificationPermission.ts`), and the split view (`useSplitViewTelemetry` in `apps/app/src/lib/split-layout/`). Bundled plugin callers: Tips from its app code and Automations from its server `create` path. Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry.
## `PluginBbSdk.hosts.experimental_discoverRepos`
diff --git a/docs/configuration.md b/docs/configuration.md
index 5721ab24253..ca6173db621 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -2090,8 +2090,13 @@ through `POST /api/v1/system/telemetry/events` (`sdk.system.experimental_recordT
`tip_shown` (once per tip per visit to the New thread page) and `tip_used` (on
click) from the bundled Tips plugin, each with the built-in tip id, its position
in the feed (1-3), and its action type (`prompt`, `open-page`, `run-command`,
-`open-plugin`, or `learn-more`). That route accepts only those events and their
-fixed, non-identifying properties.
+`open-plugin`, or `learn-more`), plus `automation_created` from the bundled
+Automations plugin when any surface creates an automation (its trigger,
+`schedule` or `once`; its mode, `agent` or `script`; and its origin, `human`,
+`app`, or `agent`) and `split_view_opened` when a pane is added to the split
+view (the pane count after it opens, 2-8; restoring a saved layout does not
+count). That route accepts only those events and their fixed, non-identifying
+properties.
Anonymous usage telemetry can be disabled in Settings → General → Privacy & diagnostics → Share anonymous usage data,
or with `bb settings general telemetryEnabled false`. The saved server-wide preference
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index 83c52d22de2..63e081f7ea2 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.6.39";
+export const PLUGIN_SDK_VERSION = "0.6.40";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 84dee8fd3fb..245aec1c637 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.6.39",
+ "version": "0.6.40",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts
index 572f1a40dc8..c2c10aa40a1 100644
--- a/packages/server-contract/src/api/system.ts
+++ b/packages/server-contract/src/api/system.ts
@@ -321,6 +321,26 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [
properties: tipTelemetryPropertiesSchema,
})
.strict(),
+ z
+ .object({
+ name: z.literal("automation_created"),
+ properties: z
+ .object({
+ trigger: z.enum(["schedule", "once"]),
+ mode: z.enum(["agent", "script"]),
+ origin: z.enum(["human", "app", "agent"]),
+ })
+ .strict(),
+ })
+ .strict(),
+ z
+ .object({
+ name: z.literal("split_view_opened"),
+ properties: z
+ .object({ panes: z.number().int().min(2).max(8) })
+ .strict(),
+ })
+ .strict(),
]);
export type RecordTelemetryEventRequest = z.infer<
typeof recordTelemetryEventRequestSchema
diff --git a/plugins/automations/PLUGIN_OVERVIEW.md b/plugins/automations/PLUGIN_OVERVIEW.md
index 06173613e73..96861fb86ee 100644
--- a/plugins/automations/PLUGIN_OVERVIEW.md
+++ b/plugins/automations/PLUGIN_OVERVIEW.md
@@ -12,3 +12,7 @@ Schedule work once or on a repeat, and let bb run it while you do something else
Agents get the `automations` skill and the `bb automation` command: `create`, `list`, `show`, `update`, `pause`, `resume`, `run`, `runs`, and `delete`. All commands accept `--json`. Threads that an automation starts cannot create automations.
Scripts run on the machine that hosts the bb server. New standard-project scripts use that host's project source when available; Personal and projects without one run in the plugin's shared script storage. Existing scripts run there until changed. A script can instead select an absolute server-host path, and its detail view shows the resolved directory. Agent runs use the providers you already have installed.
+
+## Usage data
+
+Creating an automation sends one anonymous `automation_created` event with its trigger (`schedule` or `once`), mode (`agent` or `script`), and origin (`human`, `app`, or `agent`). It carries no names, prompts, scripts, schedules, or paths, and the server drops it when Share anonymous usage data is off.
diff --git a/plugins/automations/package.json b/plugins/automations/package.json
index 39b568e1dcc..a1e47466335 100644
--- a/plugins/automations/package.json
+++ b/plugins/automations/package.json
@@ -6,7 +6,7 @@
"type": "module",
"engines": {
"bb": ">=0.0",
- "bbPluginSdk": ">=0.6.0"
+ "bbPluginSdk": ">=0.6.40"
},
"bb": {
"name": "Automations",
diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts
index 46eb808d88a..1c0c2af8257 100644
--- a/plugins/automations/src/server-harness.test.ts
+++ b/plugins/automations/src/server-harness.test.ts
@@ -77,7 +77,10 @@ async function bootAutomationsPlugin(
const host = createFakePluginHost({
pluginId: "automations",
sdk: {
- system: { config: async () => ({ primaryHostId: "host_fake" }) },
+ system: {
+ config: async () => ({ primaryHostId: "host_fake" }),
+ experimental_recordTelemetryEvent: async () => ({ ok: true }),
+ },
projects: {
async get({ projectId }) {
if (projectId === PROJECT_ID) return project(projectId);
@@ -278,6 +281,16 @@ describe("automations server plugin harness", () => {
origin: "human",
}),
).rejects.toThrow(`Project ${DELETED_PROJECT_ID} is not available`);
+ expect(
+ harness.sdk.callsTo("system.experimental_recordTelemetryEvent"),
+ ).toEqual([
+ [
+ {
+ name: "automation_created",
+ properties: { trigger: "once", mode: "agent", origin: "human" },
+ },
+ ],
+ ]);
await expect(
harness.callRpc("automations_delete", {
diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts
index 534e43e835d..dbd80765e70 100644
--- a/plugins/automations/src/service.ts
+++ b/plugins/automations/src/service.ts
@@ -25,6 +25,7 @@ import {
resolvePermissionMode,
} from "./provider-permissions.js";
import { publishAutomationChange } from "./realtime.js";
+import { recordAutomationCreated } from "./telemetry.js";
import { isPrintableWorkingDirectoryPath } from "./limits.js";
import {
AUTOMATION_RUNS_LIMIT_MAX,
@@ -651,6 +652,11 @@ export function createAutomationService(args: {
throw error;
}
publishAutomationChange(bb, payload.projectId, "automations-changed");
+ void recordAutomationCreated(bb, {
+ trigger: payload.trigger.triggerType,
+ mode: payload.execution.mode,
+ origin: payload.origin,
+ });
return withResolvedWorkingDirectory({
bb,
pluginDataDir,
diff --git a/plugins/automations/src/telemetry.ts b/plugins/automations/src/telemetry.ts
new file mode 100644
index 00000000000..61ac3f911ba
--- /dev/null
+++ b/plugins/automations/src/telemetry.ts
@@ -0,0 +1,18 @@
+import type { BbPluginApi } from "@get-bb/plugin-sdk";
+import type { AutomationOrigin, AutomationRunMode } from "./rpc-types.js";
+
+export async function recordAutomationCreated(
+ bb: Pick,
+ properties: {
+ trigger: "schedule" | "once";
+ mode: AutomationRunMode;
+ origin: AutomationOrigin;
+ },
+): Promise {
+ try {
+ await bb.sdk.system.experimental_recordTelemetryEvent({
+ name: "automation_created",
+ properties,
+ });
+ } catch {}
+}
From 83db9dd07fc98807bcbdc1576e4c7c18a91464f8 Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:50:24 -0700
Subject: [PATCH 02/13] Type the Automations telemetry call against its service
SDK surface
Automations' service receives a narrow ServiceApi, so the telemetry helper
and that type now name system.experimental_recordTelemetryEvent explicitly,
and the hand-built test SDKs stub it. Formats the PR's files with oxfmt.
Co-Authored-By: Claude Opus 5.5
---
.../useSplitViewTelemetry.test.tsx | 40 ++++++++++++++-----
.../lib/split-layout/useSplitViewTelemetry.ts | 5 ++-
.../test/system/telemetry-events.test.ts | 19 +++++++--
apps/web/src/routes/privacy.tsx | 19 +++++----
packages/server-contract/src/api/system.ts | 4 +-
plugins/automations/src/automations.test.ts | 14 ++++++-
plugins/automations/src/service.ts | 5 ++-
plugins/automations/src/telemetry.ts | 9 ++++-
8 files changed, 85 insertions(+), 30 deletions(-)
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
index b644a92b0b3..59473e967c9 100644
--- a/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
@@ -22,28 +22,45 @@ afterEach(() => {
window.localStorage.clear();
});
-const thread = (threadId: string) => ({ kind: "thread" as const, projectId: "project-1", threadId });
+const thread = (threadId: string) => ({
+ kind: "thread" as const,
+ projectId: "project-1",
+ threadId,
+});
function singlePane(): SplitLayout {
- return { root: { type: "pane", paneId: "pane-1", content: thread("thread-1") }, focusedPaneId: "pane-1" };
+ return {
+ root: { type: "pane", paneId: "pane-1", content: thread("thread-1") },
+ focusedPaneId: "pane-1",
+ };
}
function mount(initial: SplitLayout | null) {
const store = createStore();
store.set(splitLayoutAtom, initial);
- const wrapper = ({ children }: { children: ReactNode }) => {children};
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
renderHook(() => useSplitViewTelemetry(), { wrapper });
- const update = (next: (layout: SplitLayout) => SplitLayout) => act(() => {
- store.set(splitLayoutAtom, next(store.get(splitLayoutAtom) ?? singlePane()));
- });
+ const update = (next: (layout: SplitLayout) => SplitLayout) =>
+ act(() => {
+ store.set(
+ splitLayoutAtom,
+ next(store.get(splitLayoutAtom) ?? singlePane()),
+ );
+ });
return { update };
}
describe("useSplitViewTelemetry", () => {
it("reports each added pane with the pane count, and nothing for closing panes", () => {
const { update } = mount(singlePane());
- update((layout) => splitPane(layout, "pane-1", "right", thread("thread-2")));
- update((layout) => splitPane(layout, layout.focusedPaneId, "bottom", thread("thread-3")));
+ update((layout) =>
+ splitPane(layout, "pane-1", "right", thread("thread-2")),
+ );
+ update((layout) =>
+ splitPane(layout, layout.focusedPaneId, "bottom", thread("thread-3")),
+ );
update((layout) => removePane(layout, layout.focusedPaneId));
expect(mocks.recordTelemetryEvent.mock.calls).toEqual([
[{ name: "split_view_opened", properties: { panes: 2 } }],
@@ -52,7 +69,12 @@ describe("useSplitViewTelemetry", () => {
});
it("does not report a split layout restored when the app opens", () => {
- const restored = splitPane(singlePane(), "pane-1", "right", thread("thread-2"));
+ const restored = splitPane(
+ singlePane(),
+ "pane-1",
+ "right",
+ thread("thread-2"),
+ );
mount(restored);
expect(mocks.recordTelemetryEvent).not.toHaveBeenCalled();
});
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
index bfa8d63021c..70d59f1a086 100644
--- a/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
@@ -10,7 +10,10 @@ export function useSplitViewTelemetry(): void {
const previousPanes = useRef(panes);
useEffect(() => {
if (panes > previousPanes.current && panes >= 2) {
- recordTelemetryEvent({ name: "split_view_opened", properties: { panes } });
+ recordTelemetryEvent({
+ name: "split_view_opened",
+ properties: { panes },
+ });
}
previousPanes.current = panes;
}, [panes]);
diff --git a/apps/server/test/system/telemetry-events.test.ts b/apps/server/test/system/telemetry-events.test.ts
index 11aab1f041a..eecfb5afc93 100644
--- a/apps/server/test/system/telemetry-events.test.ts
+++ b/apps/server/test/system/telemetry-events.test.ts
@@ -112,7 +112,10 @@ describe("client telemetry events", () => {
};
const split = { name: "split_view_opened", properties: { panes: 2 } };
- const accepted = [await post(harness, automation), await post(harness, split)];
+ const accepted = [
+ await post(harness, automation),
+ await post(harness, split),
+ ];
const rejected = [
await post(harness, {
name: "automation_created",
@@ -122,12 +125,20 @@ describe("client telemetry events", () => {
name: "automation_created",
properties: { ...automation.properties, trigger: "interval" },
}),
- await post(harness, { name: "split_view_opened", properties: { panes: 1 } }),
- await post(harness, { name: "split_view_opened", properties: { panes: 9 } }),
+ await post(harness, {
+ name: "split_view_opened",
+ properties: { panes: 1 },
+ }),
+ await post(harness, {
+ name: "split_view_opened",
+ properties: { panes: 9 },
+ }),
];
expect(accepted.map((response) => response.status)).toEqual([200, 200]);
- expect(rejected.map((response) => response.status)).toEqual([400, 400, 400, 400]);
+ expect(rejected.map((response) => response.status)).toEqual([
+ 400, 400, 400, 400,
+ ]);
expect(capture.mock.calls).toEqual([[automation], [split]]);
});
});
diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx
index 643405f9a6c..1859a9723e7 100644
--- a/apps/web/src/routes/privacy.tsx
+++ b/apps/web/src/routes/privacy.tsx
@@ -73,16 +73,15 @@ function PrivacyRoute() {
Unless you turn it off, the bb server sends us anonymous usage
events through PostHog: app starts, thread and message counts,
plugin installs, setup progress, such as which first-run steps you
- completed or skipped and whether you turned on notifications,
- which built-in tips were shown or used, when you create an
- automation (whether it repeats or runs once, and whether it runs an
- agent or a script), and when you open a split view (how many panes
- it has). Each event carries a random
- install ID, the app version, and your operating system and
- architecture. Events never include your prompts, code, file names,
- project names, or account details. Turn this off in Settings →
- General → Share anonymous usage data, or set{" "}
- BB_TELEMETRY=false.
+ completed or skipped and whether you turned on notifications, which
+ built-in tips were shown or used, when you create an automation
+ (whether it repeats or runs once, and whether it runs an agent or a
+ script), and when you open a split view (how many panes it has).
+ Each event carries a random install ID, the app version, and your
+ operating system and architecture. Events never include your
+ prompts, code, file names, project names, or account details. Turn
+ this off in Settings → General → Share anonymous usage
+ data, or set BB_TELEMETRY=false.
The iOS and Android apps store the following on your device:
diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts
index c2c10aa40a1..33311f319ba 100644
--- a/packages/server-contract/src/api/system.ts
+++ b/packages/server-contract/src/api/system.ts
@@ -336,9 +336,7 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [
z
.object({
name: z.literal("split_view_opened"),
- properties: z
- .object({ panes: z.number().int().min(2).max(8) })
- .strict(),
+ properties: z.object({ panes: z.number().int().min(2).max(8) }).strict(),
})
.strict(),
]);
diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts
index 06273d86b72..3b6f520666e 100644
--- a/plugins/automations/src/automations.test.ts
+++ b/plugins/automations/src/automations.test.ts
@@ -176,6 +176,7 @@ function createAutomationServiceBb() {
config: async (): Promise<{ primaryHostId: string | null }> => ({
primaryHostId: "host_server",
}),
+ experimental_recordTelemetryEvent: async () => ({ ok: true as const }),
},
projects: {
get: async ({ projectId }: { projectId: string }) => {
@@ -843,6 +844,9 @@ describe("automation data access", () => {
config: async () => {
throw new Error("not expected");
},
+ experimental_recordTelemetryEvent: async () => ({
+ ok: true as const,
+ }),
},
threads: {
get: async () => {
@@ -1265,7 +1269,12 @@ describe("automation service", () => {
const db = createTestDb();
const bb = {
sdk: {
- system: { config: async () => ({ primaryHostId: null }) },
+ system: {
+ config: async () => ({ primaryHostId: null }),
+ experimental_recordTelemetryEvent: async () => ({
+ ok: true as const,
+ }),
+ },
projects: {
get: async () => {
throw new Error("Project not found");
@@ -2272,6 +2281,9 @@ describe("script project context", () => {
sdk: {
...bb.sdk,
system: {
+ experimental_recordTelemetryEvent: async () => ({
+ ok: true as const,
+ }),
config: async () => ({
primaryHostId:
args.serverHostId === undefined
diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts
index dbd80765e70..ff9368124fd 100644
--- a/plugins/automations/src/service.ts
+++ b/plugins/automations/src/service.ts
@@ -70,7 +70,10 @@ import {
type ServiceApi = Pick & {
sdk: {
- system: { config(): Promise<{ primaryHostId: string | null }> };
+ system: Pick<
+ BbPluginApi["sdk"]["system"],
+ "experimental_recordTelemetryEvent"
+ > & { config(): Promise<{ primaryHostId: string | null }> };
projects: Pick;
providers: Pick;
threads: Pick;
diff --git a/plugins/automations/src/telemetry.ts b/plugins/automations/src/telemetry.ts
index 61ac3f911ba..cf5d7163e01 100644
--- a/plugins/automations/src/telemetry.ts
+++ b/plugins/automations/src/telemetry.ts
@@ -2,7 +2,14 @@ import type { BbPluginApi } from "@get-bb/plugin-sdk";
import type { AutomationOrigin, AutomationRunMode } from "./rpc-types.js";
export async function recordAutomationCreated(
- bb: Pick,
+ bb: {
+ sdk: {
+ system: Pick<
+ BbPluginApi["sdk"]["system"],
+ "experimental_recordTelemetryEvent"
+ >;
+ };
+ },
properties: {
trigger: "schedule" | "once";
mode: AutomationRunMode;
From 406c009502ad0f961fc6ce9c75c1d262cb3e3e7c Mon Sep 17 00:00:00 2001
From: Sawyer Hood
Date: Mon, 12 Oct 2026 14:28:33 -0700
Subject: [PATCH 03/13] Add a code fence renderer plugin slot and rebuild
inline-vis on it (#5387)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
Plugins had no supported way to render a multi-line block inside an
assistant reply. `messageDirective` is single-line leaf syntax and stays
literal until it closes, so a plugin that wanted generative UI had to
scrape rendered `pre > code` elements and overlay its own UI (#5268).
Separately, the built-in `inline-vis` plugin showed HTML in a fixed
224px card and Markdown in a box that scrolled inside the thread.
## What changed
**New slot: `app.slots.experimental_codeFenceRenderer({ id, languages,
component })`.** A plugin claims fence languages and renders those
fenced code blocks in assistant and nested-agent message Markdown. The
first two commits are @ComicBit's prototype from #5268 (registration
validation, slot store, plugin guide card, test harness support). The
third commit rebuilds the rendering side, and the contract differs from
the issue's proposal:
- `status: "streaming" | "closed" | "unclosed"` replaces `complete`, so
a renderer can tell a block that is still being written from one the
reply never closed.
- The component for a fence is mounted once and kept for the whole
reply; there is no remount when the fence closes or the reply finishes.
The Markdown tree renders a placeholder, and a host around the message's
Markdown (`CodeFenceRendererHost`) owns the component and portals it
into a container that follows the placeholder from the streaming tail to
the settled region. Where the browser has `Element.moveBefore`, an
iframe inside the component is not reloaded by that move; elsewhere
component state survives and the iframe reloads once.
- A fence is identified by the source offset of its opening delimiter,
the same way images are, so the settled text is not re-parsed to number
fences. `index` (ordinal among fences of the same language) is computed
at the message level.
- A language claimed by two plugins renders BB's code block and logs a
warning, matching `messageDirective`.
- A throw affects one fence only, and BB tries the component again when
that fence's `code` or `status` changes, so a renderer that fails on a
half-streamed body recovers on the next chunk. `PluginSlotMount` gains
`crashResetKey` for this; slots that do not pass it behave as before.
- `mermaid` stays a core special case inside `Original`, because it
renders on every Markdown surface and this slot is scoped to messages.
`@get-bb/plugin-sdk` goes to 0.6.40. The plugin guide card, the
authoring skill references, `bb-guide-plugins.md`, and the
`docs/api_to_audit.md` entry describe the new contract and list what to
audit before stabilizing. No server/daemon wire fields change.
**inline-vis** (fourth commit):
- An HTML file shown with `::inline-vis{file="…"}` is now a borderless
sandboxed frame on the thread background that fits the page's height and
follows bb's theme variables live, with a hover action to open the file.
`preparePreview` returns the file's `content` and `sha256` for this.
- A fenced `inline-vis` code block whose body is an HTML page renders
the same way through the new slot, with no file. A status line shows
while the block streams, and a hover action reveals the source.
- A Markdown file shows in a card with its file name, copy and open
actions, and a preview that expands in place ("Show more") instead of
scrolling inside the card.
- The bundled skill teaches both forms, the layout rules, and the theme
variables.
Behaviour changes for existing users of the directive:
- An HTML page used to get a fixed 224px frame; it now fits its content
and `height` is a cap.
- `height` accepts 80–2000 (was 120–1200). For Markdown it is the
preview height, 256px by default (was a 224px scrolling box).
- The header toggle that hid a Markdown card, and its remembered
`bb.inline-vis.collapsed` preference, are removed.
## How you verified
- `pnpm exec turbo run typecheck lint --filter='...[origin/main]'
--continue`: 137 tasks pass.
- `pnpm exec turbo run test` for `@bb/app`, `@get-bb/plugin-sdk`,
`bb-plugin-inline-vis`, `bb-plugin-plugin-api-docs`, `@bb/templates`,
`@bb/domain`, `bb-plugin-bb-guide`: all pass (5,731 app tests, 369 SDK,
51 inline-vis, 73 plugin guide, 47 templates, 230 domain, 7 bb-guide).
- Server plugin suites (`builtin-registry`, `plugin-app-bundle`,
`bb-official-generator`, `sdk-compat`, `plugin-authoring-docs`): pass.
- `node apps/app/scripts/check-bundle-budget.mjs` after building
`@bb/app`, `node .github/workflows/check-plugin-sdk-version.mjs`, and
`node packages/plugin-sdk/scripts/check-npm-version-guard.mjs`: pass.
- New tests in `markdown-code-fence-renderers.test.tsx` cover: one
component instance and its state kept from the first streamed line
through the tail-to-settled move to the finished reply; `index` across
the two streaming regions; `status` for unclosed, closed, list-nested,
blockquoted, tilde, and over-indented fences; a crash isolated to one
fence and retried when its code changes; a contested language falling
back to BB's code block; and claims surviving the sanitized HTML
pipeline.
- Live, in a worktree dev app with a Claude Haiku thread: a reply
containing an `inline-vis` block, an HTML file directive, and Markdown
file directives rendered in light and dark themes and at phone width.
During a streamed reply the status line showed, the page's iframe was
inserted once, moved from the streaming tail to the settled region
without being disconnected, and kept its document to the end of the
reply.
Not verified: Safari and the mobile WebView (no `moveBefore`, so an
iframe reloads once when its block settles), and clicking the copy and
open-in-sidebar actions in the live app (tests cover both).
Fixes #5268
🤖 Generated with [Claude Code](https://claude.com/claude-code)
> AGENT GENERATED
---------
Co-authored-by: Leandro Piccione
Co-authored-by: Claude Code
---
.../verify-bb/features/plugin-inline-vis.md | 8 +-
.../src/components/plugin/PluginSlotMount.tsx | 47 +-
.../timeline/ConversationMessageContent.tsx | 78 +-
.../thread/timeline/ThreadTimelineRows.tsx | 129 ++--
.../components/tools/PluginCapabilities.tsx | 8 +
.../ui/markdown-code-fence-renderers.test.tsx | 507 +++++++++++++
.../ui/markdown-code-fence-renderers.tsx | 410 ++++++++++
apps/app/src/components/ui/markdown-html.tsx | 5 +
.../ui/markdown-incremental-pieces.test.ts | 1 +
.../ui/markdown-incremental-pieces.ts | 11 +
.../src/components/ui/markdown-preview.tsx | 224 +++---
apps/app/src/lib/plugin-slot-resolvers.ts | 38 +
apps/app/src/lib/plugin-slots.ts | 8 +
apps/app/src/test/fixtures/plugins.ts | 1 +
.../plugins/plugin-authoring-docs.test.ts | 10 +
docs/api_to_audit.md | 64 ++
packages/domain/src/plugin-sdk-version.ts | 2 +-
packages/plugin-sdk/package.json | 2 +-
packages/plugin-sdk/src/app-contract.ts | 78 ++
.../src/internal/plugin-app-collector.ts | 38 +
.../testing/__tests__/app-harness.test.tsx | 81 ++
packages/plugin-sdk/src/testing/app.tsx | 2 +
.../src/templates/bb-guide-plugins.md | 21 +-
.../references/backend-api-index.md | 3 +
.../references/frontend-api-index.md | 3 +
.../references/frontend-hooks-and-ui.md | 1 +
.../references/frontend-registration.md | 5 +
.../references/frontend-renderer-slots.md | 39 +-
.../bb-plugin-authoring/references/testing.md | 3 +-
plugins/inline-vis/PLUGIN_OVERVIEW.md | 36 +-
plugins/inline-vis/README.md | 138 +++-
plugins/inline-vis/app.test.tsx | 717 ++++++++++++------
plugins/inline-vis/app.tsx | 596 +++++++++++----
plugins/inline-vis/frame.test.ts | 196 +++++
plugins/inline-vis/frame.ts | 245 ++++++
plugins/inline-vis/package.json | 4 +-
plugins/inline-vis/server.test.ts | 12 +-
plugins/inline-vis/server.ts | 16 +-
plugins/inline-vis/skills/inline-vis/SKILL.md | 146 +++-
plugins/inline-vis/theme.ts | 154 ++++
plugins/inline-vis/tsconfig.json | 3 +
plugins/plugin-api-docs/src/product-map.tsx | 1 +
plugins/plugin-api-docs/src/surfaces.ts | 18 +
plugins/plugin-api-docs/src/wireframes.tsx | 14 +
.../plugin-api-docs/test/mobile-guide.test.ts | 2 +-
plugins/plugin-api-docs/test/surfaces.test.ts | 1 +
46 files changed, 3490 insertions(+), 636 deletions(-)
create mode 100644 apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx
create mode 100644 apps/app/src/components/ui/markdown-code-fence-renderers.tsx
create mode 100644 plugins/inline-vis/frame.test.ts
create mode 100644 plugins/inline-vis/frame.ts
create mode 100644 plugins/inline-vis/theme.ts
diff --git a/.bb/skills/verify-bb/features/plugin-inline-vis.md b/.bb/skills/verify-bb/features/plugin-inline-vis.md
index b17b6f0c4a6..fdfd12966df 100644
--- a/.bb/skills/verify-bb/features/plugin-inline-vis.md
+++ b/.bb/skills/verify-bb/features/plugin-inline-vis.md
@@ -22,9 +22,10 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.
| Feature | Drive | Observable success |
| --- | --- | --- |
-| Render and open | Emit the directive with a relative .html/.htm path, open its header file action, and reload the thread. | The file renders inline and opens from the correct thread workspace. |
+| Render and open | Emit the directive with a relative .html/.htm path, open the action that appears over the page on hover, and reload the thread. | The file renders inline with no card or border, follows the theme, and opens from the correct thread workspace. |
+| Fenced block | Have the reply contain an `inline-vis` fenced code block whose body is an HTML page; watch it stream, then use the hover action. | A status line shows while the block is written; the page appears when the fence closes and is not reloaded as the reply continues; the action reveals the source; an unclosed block stays a code block. |
| Relative assets | Reference a fixture stylesheet, script, and image beside the HTML. | Assets resolve from the authorized workspace location and render without unrelated filesystem access. |
-| Height | Try omitted height and values around the declared 120–1200 bounds. | Omitted height is 224px; whole numbers from 120 to 1200 are accepted. Invalid and out-of-range values show an explicit error instead of clamping. |
+| Height | Try omitted height and values around the declared 80–2000 bounds. | With no height an HTML frame fits its page and a Markdown card previews 256px, expanding in place with "Show more"; whole numbers from 80 to 2000 are accepted (a cap for HTML, the preview height for Markdown). Invalid and out-of-range values show an explicit error instead of clamping. |
| Sandbox | Have the fixture attempt to read parent DOM, cookies, and origin storage; report only success/failure. | The sandbox blocks parent-origin access while allowing the intended visualization interaction. |
| Invalid artifacts | Try missing, non-HTML, invalid UTF-8, and greater-than-5MiB fixtures. | Clear artifact errors replace the frame; no silent blank success or unrestricted fallback occurs. |
@@ -39,4 +40,5 @@ External account changes use authorized disposable targets.
## Maintenance notes
-- Omitted height uses 224px. Whole-number heights from 120 through 1200 are accepted; out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/app.tsx:17`, `plugins/inline-vis/app.tsx:30`.
+- An HTML frame fits its page (80–2000px) and `height` caps it; a Markdown card previews 256px and expands in place. Out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/frame.ts` (`FRAME_MIN_HEIGHT`, `FRAME_MAX_HEIGHT`, `frameHeight`), `plugins/inline-vis/app.tsx` (`parseRequestedHeight`).
+- The borderless frame, the 80–2000 bounds, the fenced-block recipe, and the expanding Markdown card date from 2026-10-12 and are not covered by the 2026-09-05 validation run.
diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx
index 2c49682d295..acd4305d2c0 100644
--- a/apps/app/src/components/plugin/PluginSlotMount.tsx
+++ b/apps/app/src/components/plugin/PluginSlotMount.tsx
@@ -8,7 +8,17 @@ import {
type PluginSlotOwnershipRegistry,
} from "./plugin-context";
-const crashedSlotInstances = new Set();
+const crashedSlotInstances = new Map();
+
+function isSlotInstanceCrashed(
+ instanceKey: string,
+ resetKey: string | undefined,
+): boolean {
+ return (
+ crashedSlotInstances.has(instanceKey) &&
+ crashedSlotInstances.get(instanceKey) === resetKey
+ );
+}
const ownedStateReleasesBySlotInstance = new Map<
string,
Map void>
@@ -55,7 +65,7 @@ function pluginSlotInstanceKey(
export function resetCrashedPluginSlots(pluginId: string): void {
const prefix = `${pluginId}/`;
- for (const key of [...crashedSlotInstances]) {
+ for (const key of [...crashedSlotInstances.keys()]) {
if (key.startsWith(prefix)) crashedSlotInstances.delete(key);
}
}
@@ -77,11 +87,13 @@ interface PluginSlotBoundaryProps {
instanceKey: string;
children: ReactNode;
fallback?: ReactNode;
+ resetKey?: string;
onCrash?: (pluginId: string) => void;
}
interface PluginSlotBoundaryState {
crashed: boolean;
+ resetKey: string | undefined;
}
class PluginSlotBoundary extends Component<
@@ -103,19 +115,37 @@ class PluginSlotBoundary extends Component<
constructor(props: PluginSlotBoundaryProps) {
super(props);
- this.state = { crashed: crashedSlotInstances.has(props.instanceKey) };
+ this.state = {
+ crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
+ resetKey: props.resetKey,
+ };
}
- static getDerivedStateFromError(): PluginSlotBoundaryState {
+ static getDerivedStateFromError(): Pick {
return { crashed: true };
}
+ static getDerivedStateFromProps(
+ props: PluginSlotBoundaryProps,
+ state: PluginSlotBoundaryState,
+ ): PluginSlotBoundaryState | null {
+ if (props.resetKey === state.resetKey) {
+ return null;
+ }
+ return {
+ crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
+ resetKey: props.resetKey,
+ };
+ }
+
override componentDidCatch(error: Error, info: ErrorInfo): void {
releaseSlotInstanceOwnedState(this.props.instanceKey);
this.ownedStateReleases.clear();
- crashedSlotInstances.add(this.props.instanceKey);
+ crashedSlotInstances.set(this.props.instanceKey, this.props.resetKey);
console.warn(
- `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`,
+ this.props.resetKey === undefined
+ ? `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`
+ : `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled until its input changes: ${error.message}`,
info.componentStack,
);
try {
@@ -144,7 +174,7 @@ class PluginSlotBoundary extends Component<
override render(): ReactNode {
if (
this.state.crashed ||
- crashedSlotInstances.has(this.props.instanceKey)
+ isSlotInstanceCrashed(this.props.instanceKey, this.props.resetKey)
) {
return this.props.fallback === undefined ? (
@@ -166,6 +196,7 @@ interface PluginSlotMountProps {
slotId: string;
children: ReactNode;
crashFallback?: ReactNode;
+ crashResetKey?: string;
instanceId?: string;
onCrash?: (pluginId: string) => void;
}
@@ -176,6 +207,7 @@ export function PluginSlotMount({
slotId,
children,
crashFallback,
+ crashResetKey,
instanceId,
onCrash,
}: PluginSlotMountProps) {
@@ -192,6 +224,7 @@ export function PluginSlotMount({
instanceId,
)}
fallback={crashFallback}
+ {...(crashResetKey === undefined ? {} : { resetKey: crashResetKey })}
{...(onCrash ? { onCrash } : {})}
>
+ );
+}
+
+function MarkdownCode({
+ className: codeClassName,
+ children,
+ codeFenceSourceOffset,
+ imagePolicy,
+ linkRouting,
+ node: _node,
+ preferredTheme,
+ rewriteLocalhostLinks,
+ "data-code-fence-offset": codeFenceOffset,
+ "data-code-fence-closed": codeFenceClosed,
+ ...props
+}: MarkdownCodeRendererProps) {
+ const codeText =
+ typeof children === "string" ? children.replace(/\n$/, "") : "";
+ const language = getMarkdownCodeLanguage({ className: codeClassName });
+ const isBlock = isMarkdownCodeBlock({ codeText, language });
+ if (isBlock) {
+ const original = (
+
+ );
+ return codeFenceOffset === undefined || language === null ? (
+ original
+ ) : (
+
);
}
@@ -1306,6 +1353,7 @@ function buildMarkdownComponents({
return (
- messageDirectiveMounts === null
- ? baseRemarkPlugins
- : [
- ...baseRemarkPlugins,
- [
- remarkMessageDirectives,
- {
- indexBase: 0,
- limit: MESSAGE_DIRECTIVE_MOUNT_LIMIT,
- mounts: messageDirectiveMounts.mounts,
- registry: messageDirectiveMounts.registry,
- },
- ],
- ],
- [baseRemarkPlugins, messageDirectiveMounts],
- );
+ const codeFenceRegistry = useCodeFenceLanguages();
+ const remarkPlugins = useMemo((): MarkdownRemarkPlugins => {
+ const plugins: MarkdownRemarkPlugins = [...baseRemarkPlugins];
+ if (messageDirectiveMounts !== null) {
+ plugins.push([
+ remarkMessageDirectives,
+ {
+ indexBase: 0,
+ limit: MESSAGE_DIRECTIVE_MOUNT_LIMIT,
+ mounts: messageDirectiveMounts.mounts,
+ registry: messageDirectiveMounts.registry,
+ },
+ ]);
+ }
+ if (codeFenceRegistry !== null) {
+ plugins.push([
+ remarkCodeFenceRenderers,
+ { registry: codeFenceRegistry, sourceOffset: 0 },
+ ]);
+ }
+ return plugins;
+ }, [baseRemarkPlugins, messageDirectiveMounts, codeFenceRegistry]);
const resolvedUrlTransform = useMemo(
() =>
localFileRouting || localImageRouting
@@ -1875,6 +1927,7 @@ function MarkdownPreviewComponent({
const markdownPieceRenderConfig = useMemo(
(): MarkdownPieceRenderConfig => ({
+ codeFenceRendererRegistry: codeFenceRegistry,
components: markdownComponents,
messageDirectiveRegistry: messageDirectiveMounts?.registry ?? null,
rehypePlugins,
@@ -1882,6 +1935,7 @@ function MarkdownPreviewComponent({
urlTransform: resolvedUrlTransform,
}),
[
+ codeFenceRegistry,
markdownComponents,
messageDirectiveMounts,
rehypePlugins,
diff --git a/apps/app/src/lib/plugin-slot-resolvers.ts b/apps/app/src/lib/plugin-slot-resolvers.ts
index b1f047a2129..166a5890f0a 100644
--- a/apps/app/src/lib/plugin-slot-resolvers.ts
+++ b/apps/app/src/lib/plugin-slot-resolvers.ts
@@ -4,6 +4,7 @@ import type {
PluginComposerScope,
} from "@get-bb/plugin-sdk";
import type {
+ PluginCodeFenceRendererSlot,
PluginComposerCustomizationSlot,
PluginFileOpenerSlot,
PluginMessageDirectiveSlot,
@@ -273,6 +274,43 @@ export function resolveMessageDirectiveRegistry(
return resolved;
}
+export type ResolvedCodeFenceRenderer =
+ | { status: "ok"; slot: PluginCodeFenceRendererSlot }
+ | { status: "collision"; pluginIds: readonly string[] };
+
+export function resolveCodeFenceRendererRegistry(
+ registrations: readonly PluginCodeFenceRendererSlot[],
+): ReadonlyMap {
+ const claimantsByLanguage = new Map();
+ for (const registration of registrations) {
+ for (const language of registration.languages) {
+ const claimants = claimantsByLanguage.get(language);
+ if (claimants === undefined) {
+ claimantsByLanguage.set(language, [registration]);
+ } else {
+ claimants.push(registration);
+ }
+ }
+ }
+ const resolved = new Map();
+ for (const [language, claimants] of claimantsByLanguage) {
+ const [first] = claimants;
+ if (first === undefined) continue;
+ resolved.set(
+ language,
+ claimants.length === 1
+ ? { status: "ok", slot: first }
+ : {
+ status: "collision",
+ pluginIds: [
+ ...new Set(claimants.map((claimant) => claimant.pluginId)),
+ ].sort(),
+ },
+ );
+ }
+ return resolved;
+}
+
export type ResolvedReplacement =
| { kind: "owner" }
| { kind: "plugin"; registration: Registration };
diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts
index a8a47bd0a9a..449aa5c4ee8 100644
--- a/apps/app/src/lib/plugin-slots.ts
+++ b/apps/app/src/lib/plugin-slots.ts
@@ -3,6 +3,7 @@ import { useSyncExternalStore } from "react";
import type {
ComposerCustomization,
ExperimentalAppOverlayRegistration,
+ ExperimentalCodeFenceRendererRegistration,
PluginDiffRendererRegistration,
PluginEnvironmentProviderInputsRegistration,
PluginMachineProviderInputsRegistration,
@@ -53,6 +54,7 @@ export interface PluginRegistrationSet {
sourceCodeRenderers?: readonly PluginSourceCodeRendererRegistration[];
diffRenderers?: readonly PluginDiffRendererRegistration[];
messageDirectives: readonly PluginMessageDirectiveRegistration[];
+ codeFenceRenderers?: readonly ExperimentalCodeFenceRendererRegistration[];
messageActions?: readonly PluginMessageActionRegistration[];
commandPaletteActions?: readonly CollectedPluginCommandRegistration[];
providerIcons?: readonly CollectedPluginProviderIconRegistration[];
@@ -101,6 +103,8 @@ export interface PluginDiffRendererSlot
extends PluginDiffRendererRegistration, PluginSlotBase {}
export interface PluginMessageDirectiveSlot
extends PluginMessageDirectiveRegistration, PluginSlotBase {}
+export interface PluginCodeFenceRendererSlot
+ extends ExperimentalCodeFenceRendererRegistration, PluginSlotBase {}
export interface PluginMessageActionSlot
extends PluginMessageActionRegistration, PluginSlotBase {}
export type PluginCommandPaletteActionSlot =
@@ -133,6 +137,7 @@ export interface PluginSlotSnapshot {
sourceCodeRenderers: readonly PluginSourceCodeRendererSlot[];
diffRenderers: readonly PluginDiffRendererSlot[];
messageDirectives: readonly PluginMessageDirectiveSlot[];
+ codeFenceRenderers: readonly PluginCodeFenceRendererSlot[];
messageActions: readonly PluginMessageActionSlot[];
commandPaletteActions: readonly PluginCommandPaletteActionSlot[];
providerIcons: readonly PluginProviderIconSlot[];
@@ -160,6 +165,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
@@ -194,6 +200,7 @@ const SLOT_KINDS: readonly SlotKind[] = [
"sourceCodeRenderers",
"diffRenderers",
"messageDirectives",
+ "codeFenceRenderers",
"messageActions",
"commandPaletteActions",
"providerIcons",
@@ -252,6 +259,7 @@ function flattenRegistrations(
sourceCodeRenderers: stamp(set.sourceCodeRenderers),
diffRenderers: stamp(set.diffRenderers),
messageDirectives: stamp(set.messageDirectives),
+ codeFenceRenderers: stamp(set.codeFenceRenderers),
messageActions: stamp(set.messageActions),
commandPaletteActions: stamp(set.commandPaletteActions),
providerIcons: stamp(set.providerIcons),
diff --git a/apps/app/src/test/fixtures/plugins.ts b/apps/app/src/test/fixtures/plugins.ts
index cf264653aa4..401a86ac5c2 100644
--- a/apps/app/src/test/fixtures/plugins.ts
+++ b/apps/app/src/test/fixtures/plugins.ts
@@ -39,6 +39,7 @@ export function makePluginRegistrationSet(
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
index da2f2d25ae3..365f83c376e 100644
--- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
+++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
@@ -21,6 +21,7 @@ import {
type PluginMessageActionRegistration,
type ThreadChatMessageReference,
type PluginMessageDirectiveProps,
+ type ExperimentalCodeFenceRendererProps,
type PluginNavPanelProps,
type PluginNavPanelRegistration,
type PluginNewThreadPanelProps,
@@ -279,6 +280,7 @@ type SlotPropsByName = {
experimental_sourceCodeRenderer: PluginSourceCodeRendererProps;
experimental_diffRenderer: PluginDiffRendererProps;
messageDirective: PluginMessageDirectiveProps;
+ experimental_codeFenceRenderer: ExperimentalCodeFenceRendererProps;
messageAction: PluginMessageActionContext;
commandPaletteAction: PluginCommandContext;
experimental_providerIcon: PluginProviderIconRegistration;
@@ -386,6 +388,14 @@ const FRONTEND_SLOT_PROP_FIELDS = {
"Original",
],
messageDirective: ["attributes", "source", "message", "openWorkspaceFile"],
+ experimental_codeFenceRenderer: [
+ "code",
+ "language",
+ "status",
+ "index",
+ "message",
+ "Original",
+ ],
messageAction: [
"threadId",
"message",
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 9020ffbc957..09a062aa240 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -2938,6 +2938,70 @@ is temporarily unavailable renders BB's renderer without erasing the pin.
5. **Two slots or one.** Confirm source and diff should stay separately
replaceable rather than one "code renderer" registration.
+## `app.slots.experimental_codeFenceRenderer` (`@get-bb/plugin-sdk/app`)
+
+**What it does.** `app.slots.experimental_codeFenceRenderer({ id, languages,
+component })` renders fenced code blocks of the claimed languages in assistant
+and nested-agent message Markdown with a plugin component — the same places
+`messageDirective` activates. User messages, file previews, and plugin
+Markdown keep BB's code block. `id` is unique within the plugin (letters,
+digits, `-`, `_`); `languages` is non-empty, each lowercase
+`/^[a-z0-9][a-z0-9+#._-]*$/`, and a language may be claimed once per plugin
+(duplicates across that plugin's registrations are rejected at load).
+
+The component receives `{ code, language, status, index, message, Original }`.
+`code` is the fence body so far and grows while the reply streams. `status` is
+`"streaming"` (no closing delimiter yet, reply still being written),
+`"closed"` (closing delimiter present, `code` final), or `"unclosed"` (the
+reply ended without one). `index` is the zero-based ordinal among fences of
+the same language in the message. `Original` renders BB's code block (syntax
+highlighting, copy button, mermaid diagram for `mermaid`). Inside the
+component `useBbNavigate().openThreadPanel` works as it does for
+`messageDirective`.
+
+BB mounts the component once per fence and keeps that instance for as long as
+the message stays on screen. The Markdown tree renders an empty placeholder
+where the fence sits; a host around the message's Markdown owns the component
+and portals it into a container that follows the placeholder, so the instance
+survives the placeholder moving from the streaming tail to the settled region
+and the reply finishing. A fence is identified by the source offset of its
+opening delimiter, which does not move while a reply is appended to. Where
+the browser supports `Element.moveBefore`, an iframe inside the component
+also survives those moves without reloading; elsewhere component state
+survives and the iframe reloads. State that must outlive the mounted
+component belongs under `${message.id}#${language}#${index}`.
+
+Fallbacks: BB's code block renders when two plugins claim one language (BB
+logs a console warning naming them), when the plugin is missing or disabled,
+and for a fence whose component throws. A throw affects that fence only, and
+BB tries the component again when that fence's `code` or `status` changes.
+
+**Core callers on the same path.** Assistant and nested-agent message Markdown
+rendering — `MarkdownPreview`'s code renderer, the pipeline that also resolves
+`messageDirective`. The built-in `inline-vis` plugin renders its
+` ```inline-vis ` pages through this slot. BB's `mermaid` diagram stays
+a core special case inside `Original`, because it renders on every Markdown
+surface and this slot is scoped to messages. The SDK testing harness exposes
+registrations as `loadPluginApp(...).codeFenceRenderers`, renderable with
+`renderSlot`.
+
+**Audit before stabilizing.**
+
+1. **Collision model.** Confirm that a contested language falling back to
+ BB's code block is enough, or whether users need a per-language picker
+ like the source/diff renderer pin.
+2. **Streaming contract.** Confirm `code`/`status`/`index` cover real
+ generative-UI renderers (partial JSON/DSL parsing), and whether the rest
+ of the info string should be exposed as `meta`.
+3. **Scope.** Confirm the slot should stay limited to assistant and
+ nested-agent Markdown, whether common languages (`json`, `ts`) should be
+ claimable, and whether `mermaid` should be claimable or reserved for BB.
+4. **Identity.** Confirm keying a fence by source offset holds for every
+ path that rewrites a message's text, and decide whether iframes must
+ survive the tail-to-settled move in browsers without `moveBefore`.
+5. **Source visibility.** Decide whether BB should offer its own "view
+ source" control for claimed fences instead of leaving it to each plugin.
+
## `experimental_useSidebarThreads` / `experimental_useSidebarThreadActions` (`@get-bb/plugin-sdk/app`)
**New-thread machine selection (Sep 2026).**
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index 83c52d22de2..63e081f7ea2 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.6.39";
+export const PLUGIN_SDK_VERSION = "0.6.40";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index bbda25a8f61..a0663118099 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.6.39",
+ "version": "0.6.40",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index 1e9a324a94c..117e18430f3 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -494,6 +494,51 @@ export interface PluginMessageDirectiveProps {
openWorkspaceFile: PluginMessageDirectiveOpenWorkspaceFile | null;
}
+/**
+ * Where a fenced code block stands while its message is written.
+ *
+ * - `"streaming"`: the closing delimiter has not arrived and the message is
+ * still being written; `code` is a prefix of the final body.
+ * - `"closed"`: the closing delimiter is present; `code` is final.
+ * - `"unclosed"`: the message ended without a closing delimiter (for example
+ * an interrupted reply); `code` will not grow.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export type ExperimentalCodeFenceStatus = "streaming" | "closed" | "unclosed";
+
+/**
+ * Props passed to an `experimental_codeFenceRenderer` component — one fenced
+ * code block whose info-string language the plugin claimed, inside assistant
+ * (or nested agent) message Markdown. BB mounts the component once per fence
+ * and keeps that instance for as long as the message stays on screen, so
+ * component state survives the whole stream, including the moment the fence
+ * closes and the moment the reply finishes.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export interface ExperimentalCodeFenceRendererProps {
+ /** The fence body received so far, without the fence delimiters. */
+ code: string;
+ /** The matched lowercase info-string language (e.g. `"ui"`). */
+ language: string;
+ status: ExperimentalCodeFenceStatus;
+ /**
+ * Zero-based ordinal of this fence among the fences of the same language in
+ * the same message. It does not change while the message streams, so
+ * `${message.id}#${language}#${index}` keys state that must outlive the
+ * mounted component (a reload, or the message scrolling out of the rendered
+ * window).
+ */
+ index: number;
+ message: PluginMessageDirectiveMessage;
+ /**
+ * BB's own code block for this fence. Render it to show the source, for
+ * example while `status` is `"streaming"` or when `code` does not parse.
+ */
+ Original: ComponentType;
+}
+
// ---------------------------------------------------------------------------
// Slot registrations (the arguments to `app.slots.*`).
// ---------------------------------------------------------------------------
@@ -1785,6 +1830,31 @@ export interface PluginMessageDirectiveRegistration {
component: ComponentType;
}
+/**
+ * Render fenced code blocks of chosen languages inside assistant (and nested
+ * agent) message Markdown. ` ```ui ` matches a registration whose `languages`
+ * include `"ui"`. Fences elsewhere (user messages, file previews, plugin
+ * Markdown) keep BB's code block.
+ *
+ * BB's code block also renders when two plugins claim the same language (BB
+ * logs a warning naming them), when the plugin is disabled, and for a fence
+ * whose component throws. A throw affects that fence only, and BB tries the
+ * component again when the fence's `code` or `status` changes.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export interface ExperimentalCodeFenceRendererRegistration {
+ /** Unique within the plugin; letters, digits, `-`, `_`. */
+ id: string;
+ /**
+ * Lowercase info-string languages, e.g. `["ui", "app"]`. Each starts with a
+ * letter or digit and contains only `a-z`, `0-9`, `+`, `#`, `.`, `_`, `-`.
+ * A language may be claimed once per plugin.
+ */
+ languages: readonly string[];
+ component: ComponentType;
+}
+
/**
* A narrow, stable reference to one rendered chat message — NOT an internal
* timeline row. `sourceSeqEnd` is the last source event sequence the message
@@ -2209,6 +2279,14 @@ export interface PluginAppSlots {
*/
experimental_diffRenderer(registration: PluginDiffRendererRegistration): void;
messageDirective(registration: PluginMessageDirectiveRegistration): void;
+ /**
+ * Render fenced code blocks of chosen languages in assistant messages (see
+ * {@link ExperimentalCodeFenceRendererRegistration}). Experimental: see
+ * docs/api_to_audit.md.
+ */
+ experimental_codeFenceRenderer(
+ registration: ExperimentalCodeFenceRendererRegistration,
+ ): void;
messageAction(registration: PluginMessageActionRegistration): void;
/**
* @deprecated Use `app.commands.register` with the same registration.
diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
index 9614e0f9194..a50e0b03bf2 100644
--- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts
+++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type {
ComposerCustomization,
ExperimentalAppOverlayRegistration,
+ ExperimentalCodeFenceRendererRegistration,
ExperimentalSidebarFooter,
ExperimentalSidebarFooterActionContext,
ExperimentalSidebarFooterActionRegistration,
@@ -48,6 +49,8 @@ import {
requireUniqueId,
} from "./composer-customization-validation.js";
+const CODE_FENCE_LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]*$/;
+
export type ExperimentalSidebarFooterCommandKind = "open" | "close" | "toggle";
export interface ExperimentalSidebarFooterRuntimeSnapshot {
@@ -360,6 +363,7 @@ export interface CollectedPluginAppRegistrations {
sourceCodeRenderers: PluginSourceCodeRendererRegistration[];
diffRenderers: PluginDiffRendererRegistration[];
messageDirectives: PluginMessageDirectiveRegistration[];
+ codeFenceRenderers: ExperimentalCodeFenceRendererRegistration[];
messageActions: PluginMessageActionRegistration[];
commandPaletteActions: CollectedPluginCommandRegistration[];
providerIcons: CollectedPluginProviderIconRegistration[];
@@ -485,6 +489,7 @@ export function collectPluginAppRegistrations(
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
@@ -514,6 +519,8 @@ export function collectPluginAppRegistrations(
sourceCodeRenderer: new Set(),
diffRenderer: new Set(),
messageDirective: new Set(),
+ codeFenceRenderer: new Set(),
+ codeFenceLanguage: new Set(),
messageAction: new Set(),
command: new Set(),
providerIcon: new Set(),
@@ -885,6 +892,37 @@ export function collectPluginAppRegistrations(
component: requireComponent(kind, registration.component),
});
},
+ experimental_codeFenceRenderer(registration) {
+ const kind = "slots.experimental_codeFenceRenderer";
+ const id = requireSlotId(kind, registration?.id);
+ requireUniqueId(kind, seenIds.codeFenceRenderer, id);
+ const rawLanguages: unknown = registration.languages;
+ if (!Array.isArray(rawLanguages) || rawLanguages.length === 0) {
+ throw new Error(
+ `${kind}: "languages" must be a non-empty array of lowercase info-string languages`,
+ );
+ }
+ const languages = rawLanguages.map((language: unknown) => {
+ if (
+ typeof language !== "string" ||
+ !CODE_FENCE_LANGUAGE_PATTERN.test(language)
+ ) {
+ throw new Error(
+ `${kind}: languages must match ${CODE_FENCE_LANGUAGE_PATTERN}, got ${JSON.stringify(language)}`,
+ );
+ }
+ if (seenIds.codeFenceLanguage.has(language)) {
+ throw new Error(`${kind}: duplicate language "${language}"`);
+ }
+ seenIds.codeFenceLanguage.add(language);
+ return language;
+ });
+ collected.codeFenceRenderers.push({
+ id,
+ languages,
+ component: requireComponent(kind, registration.component),
+ });
+ },
messageAction(registration) {
const kind = "slots.messageAction";
const id = requireSlotId(kind, registration?.id);
diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
index ee91ec107cf..6792f77d2e9 100644
--- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
+++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
@@ -1272,6 +1272,87 @@ describe("loadPluginApp", () => {
).rejects.toThrow('slots.messageDirective: duplicate id "inline-vis"');
});
+ it("captures experimental_codeFenceRenderer registrations and validates them like the host", async () => {
+ const FenceRenderer = () => null;
+ const captured = await loadPluginApp(
+ definePluginApp((builder) => {
+ builder.slots.experimental_codeFenceRenderer({
+ id: "generative-ui",
+ languages: ["ui", "app"],
+ component: FenceRenderer,
+ });
+ builder.slots.experimental_codeFenceRenderer({
+ id: "charts",
+ languages: ["c++", "chart.v2"],
+ component: FenceRenderer,
+ });
+ }),
+ );
+ expect(captured.codeFenceRenderers).toEqual([
+ {
+ id: "generative-ui",
+ languages: ["ui", "app"],
+ component: FenceRenderer,
+ },
+ {
+ id: "charts",
+ languages: ["c++", "chart.v2"],
+ component: FenceRenderer,
+ },
+ ]);
+
+ const kind = "slots.experimental_codeFenceRenderer";
+ const rejected: [string, Record[]][] = [
+ [
+ `${kind}: "languages" must be a non-empty array`,
+ [{ id: "ui", languages: [], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: "languages" must be a non-empty array`,
+ [{ id: "ui", languages: "ui", component: FenceRenderer }],
+ ],
+ [
+ `${kind}: languages must match`,
+ [{ id: "ui", languages: ["UI"], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: languages must match`,
+ [{ id: "ui", languages: ["ui block"], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: duplicate language "ui"`,
+ [
+ { id: "first", languages: ["ui"], component: FenceRenderer },
+ { id: "second", languages: ["app", "ui"], component: FenceRenderer },
+ ],
+ ],
+ [
+ `${kind}: duplicate id "ui"`,
+ [
+ { id: "ui", languages: ["ui"], component: FenceRenderer },
+ { id: "ui", languages: ["app"], component: FenceRenderer },
+ ],
+ ],
+ [
+ `${kind}: "component"`,
+ [{ id: "ui", languages: ["ui"], component: undefined }],
+ ],
+ ];
+ for (const [message, registrations] of rejected) {
+ await expect(
+ loadPluginApp(
+ definePluginApp((builder) => {
+ for (const registration of registrations) {
+ builder.slots.experimental_codeFenceRenderer(
+ registration as never,
+ );
+ }
+ }),
+ ),
+ ).rejects.toThrow(message);
+ }
+ });
+
it("captures messageAction registrations and validates them like the host", async () => {
expect(app.messageActions).toHaveLength(1);
expect(app.messageActions[0]).toMatchObject({
diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx
index 23986d1d5bb..c2f094936a2 100644
--- a/packages/plugin-sdk/src/testing/app.tsx
+++ b/packages/plugin-sdk/src/testing/app.tsx
@@ -43,6 +43,7 @@ import {
type PluginHomepageSectionRegistration,
type PluginMessageActionRegistration,
type PluginMessageDirectiveRegistration,
+ type ExperimentalCodeFenceRendererRegistration,
type PluginDiffRendererRegistration,
type PluginNavPanelRegistration,
type PluginNewThreadPanelActionRegistration,
@@ -1507,6 +1508,7 @@ export interface CapturedPluginApp {
sourceCodeRenderers: PluginSourceCodeRendererRegistration[];
diffRenderers: PluginDiffRendererRegistration[];
messageDirectives: PluginMessageDirectiveRegistration[];
+ codeFenceRenderers: ExperimentalCodeFenceRendererRegistration[];
messageActions: PluginMessageActionRegistration[];
providerIcons: CollectedPluginProviderIconRegistration[];
icons: ExperimentalIconRegistration[];
diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md
index f0956264ab7..3a8be4a4836 100644
--- a/packages/templates/src/templates/bb-guide-plugins.md
+++ b/packages/templates/src/templates/bb-guide-plugins.md
@@ -731,14 +731,19 @@ experimental_newThreadPanelAction (the root New thread counterpart, with
`projectId: string | null` instead of `threadId`), pendingInteraction (temporarily replace a thread composer with a
plugin form), fileOpener (register as a per-extension file viewer/editor;
users pick defaults under Settings → File openers and can right-click a
-file link for a one-off choice), and messageDirective (replace a leaf
+file link for a one-off choice), messageDirective (replace a leaf
`::name{k="v"}` block inside assistant / nested-agent Markdown with a plugin
component; unknown, disabled, incomplete, code-fenced, or crashing
directives fall back to the original source; components receive a nullable
openWorkspaceFile(path) callback for opening a worktree-relative file in the
host workspace viewer and a nullable
openThreadPanel({ actionId, title?, params? }) callback for opening one of the
-same plugin's thread-panel actions). Hooks:
+same plugin's thread-panel actions), and experimental_codeFenceRenderer
+(render fenced code blocks of claimed languages in the same assistant /
+nested-agent Markdown with a plugin component receiving { code, language,
+status, index, message, Original }, mounted once per fence for the whole
+stream; BB's code block is the fallback for a disabled plugin, a language two
+plugins claim, and a fence whose component throws). Hooks:
useRpc, useRealtime, useRealtimeConnectionState (the shared realtime socket's
connecting/connected/reconnecting lifecycle; reconcile on later connected
transitions, not the initial connection), useSettings (secrets excluded),
@@ -963,7 +968,7 @@ reload/disable/shutdown).
Frontend entries register React slots (homepageSection, settingsSection,
navPanel, threadPanelAction, experimental_newThreadPanelAction, fileOpener,
-messageDirective) and composer
+messageDirective, experimental_codeFenceRenderer) and composer
customizations via `app.composer.customize({ actions, plusMenu, sendMenu,
banners, richText })`; action/banner components use `useComposer()`, while the
host renders plus-menu and send-menu rows and editor decorations. The deprecated pre-1.0 `slots.composerAccessory` footer API was
@@ -978,10 +983,12 @@ For the complete authoring reference — exact signatures, working snippets
for every surface, the reload lifecycle, testing tips, and gotchas — use
the built-in `bb-plugin-authoring` skill (agents: it loads on demand;
humans: plugins/bb-guide/skills/bb-plugin-authoring/
-in a checkout). The builtin `inline-vis` plugin renders
-`::inline-vis{file="demo.html" height="480"}` through the sidebar's
-path-shaped, sandboxed worktree HTML iframe preview; `height` is optional.
-Its card header includes an open-in-sidebar action for the source HTML file.
+in a checkout). The builtin `inline-vis` plugin shows an HTML page as part
+of the reply, in a borderless sandboxed frame that follows the page's height
+and bb's theme. The page comes from `::inline-vis{file="demo.html"}` (a
+workspace or thread-storage file; an optional `height` caps the frame) or
+from a fenced `inline-vis` code block whose body is the page. A button that
+appears on hover opens the file in the sidebar, or reveals a block's source.
The `plugins/` directory contains every bundled plugin: the auto-installed
builtins and the store-only BB Official GitHub, Docs, Memory, and Tasks
plugins. The `examples/plugins/` reference plugins cover slack-bot (webhook
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
index f57fe68e256..b540e1b6c94 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
@@ -200,7 +200,10 @@ Read the installed declarations for exact current signatures.
- `PluginMessageDirectiveMessage`
- `PluginMessageDirectiveOpenWorkspaceFile`
- `PluginMessageDirectiveProps`
+- `ExperimentalCodeFenceRendererProps`
- `PluginMessageDirectiveRegistration`
+- `ExperimentalCodeFenceRendererRegistration`
+- `ExperimentalCodeFenceStatus`
- `PluginNavPanelProps`
- `PluginNavPanelRegistration`
- `PluginNewThreadPanelActionContext`
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
index 2df4c98381b..45b8f68cf3e 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
@@ -134,6 +134,7 @@ Read the installed SDK declarations for the exact current signatures.
- `PluginMessageDirectiveMessage`
- `PluginMessageDirectiveOpenWorkspaceFile`
- `PluginMessageDirectiveProps`
+- `ExperimentalCodeFenceRendererProps`
- `PluginHomepageSectionRegistration`
- `PluginSettingsSectionRegistration`
- `ExperimentalAppOverlayRegistration`
@@ -207,6 +208,8 @@ Read the installed SDK declarations for the exact current signatures.
- `PluginSourceCodeRendererRegistration`
- `PluginDiffRendererRegistration`
- `PluginMessageDirectiveRegistration`
+- `ExperimentalCodeFenceRendererRegistration`
+- `ExperimentalCodeFenceStatus`
- `ThreadChatMessageReference`
- `PluginTargetedPanelActionOpenOptions`
- `PluginMessageActionContext`
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
index ad99680fb3e..a91d9d74d53 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
@@ -273,6 +273,7 @@ compact viewports (the host's responsive behavior) — same API.
Crash isolation stays local to the slot. Additive slots can show a crash chip
or nothing. Replacement slots fall back to the native list or renderer.
`messageDirective` falls back to the original source text.
+`experimental_codeFenceRenderer` falls back to BB's code block (`Original`).
The `run` pattern (threadPanelAction): `run` is the place to resolve
server state before deciding what to open — e.g. call a backend rpc, then
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
index 5670c256460..27bd1efdb4c 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
@@ -124,6 +124,11 @@ export default definePluginApp((app) => {
onActivate: ({ openPluginDetails }) => openPluginDetails(),
});
app.slots.messageDirective({ id: "inline-vis", component: InlineVis });
+ app.slots.experimental_codeFenceRenderer({
+ id: "ui-blocks",
+ languages: ["ui"],
+ component: UiBlock,
+ });
app.slots.experimental_threadList({
id: "inbox",
title: "Inbox",
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
index 9837af4467d..3c8192a6ce9 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
@@ -65,8 +65,43 @@ openWorkspaceFile }` — register a leaf
attributes as attacker-controlled even though the model emitted them;
load workspace data through `bb.sdk.files` with root/host confinement
rather than trusting paths. Reference implementation:
- `plugins/inline-vis` (the sidebar's path-shaped, sandboxed worktree
- iframe preview, including relative assets and normal web loading).
+ `plugins/inline-vis` (a workspace or thread-storage HTML file shown in a
+ borderless, sandboxed frame that follows the page's height and bb's theme,
+ including relative assets and normal web loading).
+- `experimental_codeFenceRenderer` → `{ code, language, status, index,
+message, Original }` — render fenced code blocks of chosen languages in
+ assistant-message Markdown with a plugin component (e.g. a ` ```ui `
+ block rendered live as the reply streams). Registration:
+ `{ id, languages, component }`; `id` uses letters, digits, `-`, `_` and is
+ unique within the plugin; `languages` is non-empty and each entry matches
+ `/^[a-z0-9][a-z0-9+#._-]*$/`. A plugin may claim a language once across its
+ registrations (duplicates are rejected at load). Props: `code` is the fence
+ body so far, without the delimiters; `status` is `"streaming"` while the
+ closing fence has not arrived and the reply is still being written,
+ `"closed"` once the closing fence is present (`code` is final), and
+ `"unclosed"` when the reply ended without one (for example an interrupted
+ reply); `index` is the zero-based ordinal among fences of the same language
+ in the message (other languages don't shift it); `message` matches
+ `messageDirective`'s; `Original` renders BB's code block (highlighting,
+ copy button, mermaid diagrams). BB mounts the component once per fence and
+ keeps that instance while the message stays on screen, so component state
+ survives the whole stream, including the fence closing and the reply
+ finishing. State that must outlive the mounted component (a reload, the
+ message scrolling out of the rendered window) belongs under
+ `${message.id}#${language}#${index}`. `useBbNavigate().openThreadPanel`
+ works inside it as for `messageDirective`. **Host behavior / fallbacks:**
+ activates only where `messageDirective` does (assistant and nested agent
+ Markdown); user messages, file previews, and plugin Markdown keep BB's code
+ block. BB's code block also renders when two plugins claim the same
+ language (BB logs a console warning naming them), when the plugin is
+ missing or disabled, and for a fence whose component throws. A throw
+ affects that fence only, and BB tries the component again when the fence's
+ `code` or `status` changes, so a renderer that fails on a half-streamed
+ body recovers on the next chunk. Treat `code` as attacker-controlled even
+ though the model emitted it: run generated markup or scripts in a sandboxed
+ frame, never in the bb page. Reference implementation:
+ `plugins/inline-vis` (a ` ```inline-vis ` block holding an HTML page).
+ Experimental: see `docs/api_to_audit.md`.
- `messageAction` → an action on chat messages: an icon button in the
per-message action bar (user and assistant messages) and an entry in the
assistant-message text-selection menu. Slot actions are limited to the main
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
index c77dd1c930a..f084fe5b9a5 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
@@ -190,7 +190,8 @@ await contentScripts.lifecycle.dispose();
`loadPluginApp` validates registrations with the host's own rules (slot id
patterns, settingsSection optional title, navPanel path,
-fileOpener extensions, and content-script ids/mount functions) and returns
+fileOpener extensions, code-fence renderer languages (exposed as
+`app.codeFenceRenderers`), and content-script ids/mount functions) and returns
them typed with defaults filled. `mountPluginContentScripts` mirrors ordered
mount, abort-before-cleanup, reverse rollback, exact-once disposal, and
per-window instances. Working examples:
diff --git a/plugins/inline-vis/PLUGIN_OVERVIEW.md b/plugins/inline-vis/PLUGIN_OVERVIEW.md
index 177dc1f54b7..4cb213149cf 100644
--- a/plugins/inline-vis/PLUGIN_OVERVIEW.md
+++ b/plugins/inline-vis/PLUGIN_OVERVIEW.md
@@ -1,33 +1,39 @@
-See an agent's chart, demo, report, or Markdown document in the conversation without opening a side panel. The agent writes an HTML or Markdown file to the workspace or the thread's storage directory. The plugin shows that file inside the assistant message.
+See an agent's chart, mockup, demo, report, or Markdown document in the conversation without opening a side panel. The agent either writes a small HTML page directly in its reply, or writes an HTML or Markdown file to the workspace or the thread's storage directory. The plugin shows the page inside the assistant message.
## What you get
-- A live HTML preview or formatted Markdown document in the message.
-- A default viewport height of 224 pixels. The agent can set a height from 120 to 1200 pixels.
-- A header action that opens the source file in bb's sidebar viewer, for workspace and thread-storage previews alike.
-- Cmd-click, Ctrl-click, or middle-click that action on an HTML preview to open the rendered page in a browser tab instead.
-- A collapse control that remembers whether inline previews should stay collapsed on the current client.
-- A clear inline error when the file is missing, too large, or unsupported.
+- An HTML page shown as part of the reply: no card and no title bar, on the thread's own background, as tall as its content.
+- The page in your theme. It gets bb's colours, radius, shadows, and font as CSS variables, and follows a theme or light/dark change live without reloading.
+- For a page from a file, a button that appears on hover and opens the file in bb's sidebar viewer. Cmd-click, Ctrl-click, or middle-click it to open the rendered page in a browser tab instead.
+- For a page written in the reply, a button that appears on hover and shows or hides the HTML source below the page.
+- A formatted Markdown document in a card that shows its file name, with buttons to copy the Markdown and open the file in the sidebar. A long document shows a preview and expands in place with "Show more".
+- A clear inline error when a file is missing, too large, or unsupported.
## How it works
-The agent emits a message directive that names a source-relative `.html`, `.htm`, `.md`, or `.markdown` file. Omitting `source` defaults to the workspace, and explicit `source="workspace"` is equivalent:
+There are two ways for the agent to show a page.
-```text
-::inline-vis{file="charts/out.html" height="480"}
-::inline-vis{file="reports/summary.md"}
+A fenced code block with the language `inline-vis` holds the HTML page itself. Nothing is written to disk. While the reply is still streaming, the block shows a short status line. The page appears when the closing fence arrives. If the reply ends before the fence closes, bb shows its normal code block.
+
+```inline-vis
+
…
```
-Read-only artifacts in the thread's storage directory can be rendered without resolving the workspace:
+A message directive names a source-relative `.html`, `.htm`, `.md`, or `.markdown` file. Omitting `source` defaults to the workspace, and explicit `source="workspace"` is equivalent:
```text
+::inline-vis{file="charts/out.html"}
+::inline-vis{file="charts/out.html" height="480"}
+::inline-vis{file="reports/summary.md"}
::inline-vis{source="thread-storage" file="reports/result.html"}
```
-The plugin confirms the file exists in the selected source before it renders. Files must be UTF-8 text with a maximum size of 5 MiB. Relative assets next to HTML files load as usual.
+For a directive, the plugin checks the path, confirms the file exists in the selected source, and reads its text. Files must be UTF-8 text with a maximum size of 5 MiB. Relative assets next to an HTML file load as usual. A page written in a fenced block has no sibling files.
+
+An HTML frame fits its page: the page reports its content height and the frame follows it, from 80 to 2000 pixels. A `height` on the directive caps the frame, and a taller page then scrolls inside it. A Markdown card uses `height` as the height of its scrolling area.
-HTML runs in a sandboxed iframe with an opaque origin. Scripts in the file cannot read the bb page, its cookies, or its storage. Markdown uses bb's renderer with sanitized HTML support.
+HTML runs in a sandboxed iframe with an opaque origin. Scripts in the page cannot read the bb page, its cookies, or its storage. A page cannot open windows. When the reader clicks a link to another site, the app opens it in a new tab. Markdown uses bb's renderer with sanitized HTML support.
## For agents
-The bundled `inline-vis` skill teaches the agent when to emit the directive and how to write the file. No account or external service is required.
+The bundled `inline-vis` skill teaches the agent when to use a fenced block and when to use the directive, how to lay a page out so it reads as part of the reply, and which theme variables to style with. No account or external service is required.
diff --git a/plugins/inline-vis/README.md b/plugins/inline-vis/README.md
index 6540401e7c4..3e2f377e44a 100644
--- a/plugins/inline-vis/README.md
+++ b/plugins/inline-vis/README.md
@@ -1,7 +1,33 @@
# bb-plugin-inline-vis
-Builtin plugin for the assistant **message directive** slot
-(`app.slots.messageDirective`). When the model emits:
+Builtin plugin that shows HTML pages and Markdown documents inside assistant
+messages. It fills two slots: the assistant **message directive** slot
+(`app.slots.messageDirective`) and the **code fence renderer** slot
+(`app.slots.experimental_codeFenceRenderer`).
+
+## Fenced block
+
+A fenced code block with the language `inline-vis` holds the HTML page itself:
+
+````text
+```inline-vis
+
…
+```
+````
+
+bb mounts the plugin's component once for the fence and keeps it for the whole
+stream. The component renders by fence status:
+
+- `streaming`: a short "Writing visualization…" status line.
+- `closed`: the page in the frame described below. A button that appears on
+ hover shows or hides bb's normal code block for the fence below the frame.
+- `unclosed`, or a fence whose body is empty: bb's normal code block.
+
+The page needs no file and no RPC. Its base URL is the app's root, so it
+cannot reference workspace files by relative path. A page that contains three
+backticks needs a four-backtick fence.
+
+## Directive
```text
::inline-vis{file="demo.html"}
@@ -15,36 +41,78 @@ For a read-only artifact in the current thread's storage directory, use:
::inline-vis{source="thread-storage" file="reports/result.html"}
```
-Set an optional preview height in pixels with `height`:
+Set an optional height in pixels with `height`, a whole number from 80 through
+2000:
```text
::inline-vis{file="demo.html" height="480"}
```
-The default is 224px; accepted values are whole numbers from 120 through 1200.
+For an HTML file, `height` caps the frame, and a taller page scrolls inside
+it. Without it the frame fits the page. For a Markdown file, `height` is the
+height of the card's preview, 256px by default; a longer document expands in
+place.
-bb replaces that leaf with this plugin's React component, which:
+bb replaces the directive with this plugin's React component, which:
-1. Validates the untrusted `source` and `file` attributes.
+1. Validates the untrusted `source`, `file`, and `height` attributes.
2. Calls the plugin RPC `preparePreview` with the message `threadId`, source,
- and file path to validate the target and surface clean inline errors.
-3. Shows loading / error states on the first read. The last eight successful
- preview results are retained in memory by thread, message, source, and path; remounts
- revalidate without discarding them on temporary read failures. A confirmed missing
- file clears its cached result and shows an error. Every preview includes a header action that
- opens the source file in bb's sidebar viewer, from the workspace or the
- thread's storage directory. For HTML previews that action is a link to the
- rendered page: Cmd-click, Ctrl-click, or middle-click opens it in a browser
- tab (the default browser in desktop bb). The header also collapses or expands the
- preview and remembers that preference on the current client.
-4. Points HTML files at bb's existing path-shaped worktree or thread storage
- route inside a sandboxed iframe. Relative sibling assets work, scripts are
- enabled, and normal web loading is allowed. The iframe keeps an opaque
- origin (no `allow-same-origin`) so scripts cannot access the bb page, its
- cookies, or storage. Remote scripts, styles, images, fonts, media, fetches,
- and WebSockets work subject to ordinary browser CORS, mixed-content, and
- remote-server policies.
-5. Renders Markdown files with bb's Markdown renderer. HTML is sanitized by the shared renderer.
+ and file path. The RPC validates the target and returns the file's text.
+ For HTML it also returns the URL of the served file and the SHA-256 of the
+ text.
+3. Shows loading and error states on the first read. An HTML file reserves
+ blank space while it loads; a Markdown file shows its card with a skeleton.
+ The last eight successful results are retained in memory by thread,
+ message, source, and path. Remounts revalidate without discarding them on
+ temporary read failures. A confirmed missing file clears its cached result
+ and shows an error.
+4. Renders HTML files in the frame described below, with the served file's URL
+ as the base URL so relative sibling assets load. A button that appears on
+ hover opens the source file in bb's sidebar viewer, from the workspace or
+ the thread's storage directory. The button is a link to the rendered page:
+ Cmd-click, Ctrl-click, or middle-click opens it in a browser tab (the
+ default browser in desktop bb).
+5. Renders Markdown files with bb's Markdown renderer inside a card. HTML is
+ sanitized by the shared renderer. The card header shows the file name and
+ folder, copies the Markdown source, and opens the file in the sidebar
+ viewer. A document more than 64px taller than the preview height is
+ clamped to it, with a fade and a "Show more" control that expands the
+ document in place; nothing scrolls inside the card. An expanded document
+ stays expanded when its message is rendered again in the same session.
+ Markdown links and images resolve
+ relative to the document's directory in the selected source. For
+ `::inline-vis{source="thread-storage" file="reports/report.md"}`,
+ `[Notes](notes.md)` and `` refer to files under
+ `reports/` in that thread's storage. The same rule applies to workspace
+ reports.
+
+## The HTML frame
+
+Both forms render HTML the same way (`frame.ts`, `theme.ts`, and `HtmlFrame`
+in `app.tsx`):
+
+- The page is an iframe with no card, title bar, or border, on the thread's
+ own background.
+- The iframe is fed by `srcdoc`. `frameDocument` adds a charset, a viewport,
+ a `` (unless the page has its own), the theme stylesheet, and a small
+ bootstrap script at the start of the page's ``.
+- The sandbox is `allow-scripts allow-forms` without `allow-same-origin`, so
+ the page keeps an opaque origin. Its scripts cannot access the bb page, its
+ cookies, or its storage. Remote scripts, styles, images, fonts, media,
+ fetches, and WebSockets work subject to ordinary browser CORS,
+ mixed-content, and remote-server policies.
+- The theme is bb's active tokens as CSS custom properties on `:root`
+ (`FRAME_THEME_TOKENS` in `frame.ts`), plus a six-colour chart series and the
+ app's sans font. When the app's theme or light/dark mode changes, the host
+ posts the new values and the bootstrap applies them without a reload.
+- The bootstrap reports the page's content height, and the frame follows it
+ from 80 to 2000 pixels. The last reported height is remembered per page
+ (by content hash for a file, by message and fence index for a fenced block)
+ so a remounted frame starts at the right size.
+- A page cannot open windows. When the reader clicks an `http` or `https`
+ link, the bootstrap asks the host, and the host opens it in a new tab only
+ if the frame has focus and the click is a user activation. Anchor links
+ move within the page.
## Backend security
@@ -55,22 +123,18 @@ storage previews use `bb.sdk.threads.storageLocation` instead and do not resolve
the workspace. Both sources confine the relative `.html`, `.htm`, `.md`, or
`.markdown` path under the returned root and read it through `bb.sdk.files`
(host-routed). Absolute paths, traversal, unsupported extensions, missing files,
-non-UTF-8 content, and files over 5 MiB are rejected. HTML previews then use
-bb's existing confined worktree or thread-storage route to serve the document
-and relative assets; Markdown previews render the validated content returned by
-the RPC.
+non-UTF-8 content, and files over 5 MiB are rejected. HTML previews render the
+validated content returned by the RPC and use bb's existing confined worktree
+or thread-storage route for relative assets and for opening the page in a
+browser tab. Markdown previews render the validated content returned by the
+RPC.
-It ships with bb and is reconciled through the builtin plugin lifecycle. Ship
-a supported file in either source, then ask the agent to show it with the
-directive (see the bundled `inline-vis` skill).
+It ships with bb and is reconciled through the builtin plugin lifecycle. Ask
+the agent to show a page with either form (see the bundled `inline-vis`
+skill).
## Tests
```bash
-pnpm exec turbo run test typecheck --filter=bb-plugin-inline-vis
+pnpm exec turbo run test typecheck lint --filter=bb-plugin-inline-vis
```
-
-Markdown links and images resolve relative to the document's directory in the
-selected source. For `::inline-vis{source="thread-storage" file="reports/report.md"}`,
-`[Notes](notes.md)` and `` refer to files under `reports/`
-in that thread's storage. The same rule applies to workspace reports.
diff --git a/plugins/inline-vis/app.test.tsx b/plugins/inline-vis/app.test.tsx
index 980c58c0bd7..80a0b97c051 100644
--- a/plugins/inline-vis/app.test.tsx
+++ b/plugins/inline-vis/app.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, waitFor } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
let app: Awaited>;
@@ -11,7 +11,10 @@ beforeEach(async () => {
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
window.localStorage.clear();
+ document.documentElement.classList.remove("dark");
});
const message = {
@@ -21,6 +24,117 @@ const message = {
projectId: "proj_1",
};
+function directiveProps(attributes: Record) {
+ const source = `::inline-vis{${Object.entries(attributes)
+ .map(([name, value]) => `${name}="${value}"`)
+ .join(" ")}}`;
+ return { attributes, source, message, openWorkspaceFile: null };
+}
+
+function htmlPreview(
+ file: string,
+ overrides: { content?: string; sha256?: string } = {},
+) {
+ return {
+ kind: "html" as const,
+ file,
+ source: "workspace" as const,
+ target: { kind: "workspace" as const, environmentId: "env_1", path: file },
+ url: `/api/v1/environments/env_1/files/${file
+ .split("/")
+ .map(encodeURIComponent)
+ .join("/")}`,
+ content: overrides.content ?? "