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 } : {})} >
({ id, threadId, turnId, projectId: projectId ?? null }), + [id, threadId, turnId, projectId], + ); + const markdown = ( + <> + + {streamingSplit === null ? null : ( + + )} + + ); + return (
- - {streamingSplit === null ? null : ( - + {codeFenceRendererRegistry === null ? ( + markdown + ) : ( + + {markdown} + )} getPluginSlotSnapshot().messageDirectives, () => EMPTY_PLUGIN_SLOT_SNAPSHOT.messageDirectives, ); + const codeFenceRendererSlots = useSyncExternalStore( + subscribePluginSlots, + () => getPluginSlotSnapshot().codeFenceRenderers, + () => EMPTY_PLUGIN_SLOT_SNAPSHOT.codeFenceRenderers, + ); const messageActionSlots = useSyncExternalStore( subscribePluginSlots, () => getPluginSlotSnapshot().messageActions, @@ -2298,6 +2307,10 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { () => buildMessageDirectiveRegistry(messageDirectiveSlots), [messageDirectiveSlots], ); + const codeFenceRendererRegistry = useMemo( + () => buildCodeFenceRendererRegistry(codeFenceRendererSlots), + [codeFenceRendererSlots], + ); const onSelectionAddToChat = props.onSelectionAddToChat; const timelineThreadId = props.threadId; const hasPluginSelectionActions = @@ -2447,70 +2460,72 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { ); return ( - - - - - + + + + - - - - - - + + + + {hasSelectionActions ? ( + - - - {hasSelectionActions ? ( - - ) : null} - - - - - - - - + ) : null} + + + + + + + + + ); } diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx index e42e1685211..d598aaf5211 100644 --- a/apps/app/src/components/tools/PluginCapabilities.tsx +++ b/apps/app/src/components/tools/PluginCapabilities.tsx @@ -366,6 +366,14 @@ function pluginAppSurfaceItems( detail: "Renders plugin-provided content inside thread messages.", mono: true, })), + ...slots.codeFenceRenderers + .filter((slot) => slot.pluginId === pluginId) + .map((slot) => ({ + key: `code-fence:${slot.id}`, + label: slot.languages.map((language) => `\`\`\`${language}`).join(", "), + detail: "Renders these fenced code blocks inside assistant messages.", + mono: true, + })), ]; } diff --git a/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx b/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx new file mode 100644 index 00000000000..b99d130e974 --- /dev/null +++ b/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx @@ -0,0 +1,507 @@ +// @vitest-environment jsdom + +import { useEffect, useState, type ComponentType } from "react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExperimentalCodeFenceRendererProps } from "@get-bb/plugin-sdk"; +import { MemoryRouter } from "react-router-dom"; +import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { ConversationMessageContent } from "@/components/thread/timeline/ConversationMessageContent"; +import { ThreadTimelineRows } from "@/components/thread/timeline/ThreadTimelineRows"; +import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; +import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, + type PluginCodeFenceRendererSlot, +} from "@/lib/plugin-slots"; +import { + buildCodeFenceRendererRegistry, + CodeFenceRendererHost, + CodeFenceRendererRegistryProvider, + type CodeFenceRendererRegistry, +} from "./markdown-code-fence-renderers"; +import { MarkdownPreview } from "./markdown-preview"; + +const MESSAGE = { + id: "msg_1", + threadId: "thr_1", + turnId: "turn_1", + projectId: "proj_1", +} as const; + +function UiFence(props: ExperimentalCodeFenceRendererProps) { + return ( +
+ ); +} + +function slot( + component: ComponentType, + overrides: Partial = {}, +): PluginCodeFenceRendererSlot { + return { + id: "generative-ui", + pluginId: "demo", + generation: 1, + languages: ["ui"], + component, + ...overrides, + }; +} + +function renderedFences() { + return screen.getAllByTestId("ui-fence").map((node) => ({ + code: node.getAttribute("data-code"), + status: node.getAttribute("data-status"), + index: node.getAttribute("data-index"), + })); +} + +function assistantMessage( + registry: CodeFenceRendererRegistry, + text: string, + streaming = false, +) { + return ( + + + + + + + + ); +} + +function hostedMarkdown( + registry: CodeFenceRendererRegistry, + content: string, + streaming = false, +) { + return ( + + + + ); +} + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +describe("code fence renderers in assistant messages", () => { + it("renders claimed fences with code, language, per-language index, status, and message", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render( + assistantMessage( + registry, + [ + "Intro", + "", + "```ui", + "{ first }", + "```", + "", + "```ts", + "const between = 1;", + "```", + "", + "```UI extra-meta", + "{ second }", + "```", + ].join("\n"), + ), + ); + + expect(renderedFences()).toEqual([ + { code: "{ first }", status: "closed", index: "0" }, + { code: "{ second }", status: "closed", index: "1" }, + ]); + const first = screen.getAllByTestId("ui-fence")[0]; + expect(first?.getAttribute("data-language")).toBe("ui"); + expect(first?.getAttribute("data-message-id")).toBe("msg_a"); + expect(first?.getAttribute("data-thread-id")).toBe("thr_a"); + expect(first?.getAttribute("data-turn-id")).toBe("turn_a"); + expect(first?.getAttribute("data-project-id")).toBe("proj_a"); + expect(view.container.textContent).toContain("const between = 1;"); + expect( + view.container.querySelectorAll('button[aria-label="Wrap long lines"]'), + ).toHaveLength(1); + }); + + it("renders claimed fences in document order between the surrounding prose", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render( + assistantMessage(registry, "Before\n\n```ui\n{ a }\n```\n\nAfter"), + ); + const fence = screen.getByTestId("ui-fence"); + const before = screen.getByText("Before"); + const after = screen.getByText("After"); + expect(view.container.contains(fence)).toBe(true); + expect( + before.compareDocumentPosition(fence) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + fence.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("keeps BB's code block in user messages", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render( + + + + + + + , + ); + expect(screen.queryByTestId("ui-fence")).toBeNull(); + expect(view.container.textContent).toContain("{ mine }"); + }); + + it("keeps one component instance and its state from the first streamed line until the reply has finished", () => { + let mounts = 0; + function StatefulFence(props: ExperimentalCodeFenceRendererProps) { + const [clicks, setClicks] = useState(0); + useEffect(() => { + mounts += 1; + }, []); + return ( + + ); + } + const registry = buildCodeFenceRendererRegistry([slot(StatefulFence)]); + const steps: ReadonlyArray = [ + ["Intro\n\n```ui\n{ par", "{ par", "streaming"], + ["Intro\n\n```ui\n{ partial }\n", "{ partial }", "streaming"], + ["Intro\n\n```ui\n{ partial }\n```\n", "{ partial }", "closed"], + [ + "Intro\n\n```ui\n{ partial }\n```\n\nLater prose.\n\nMore pro", + "{ partial }", + "closed", + ], + ]; + const view = render(assistantMessage(registry, steps[0]![0], true)); + fireEvent.click(screen.getByTestId("stateful-fence")); + for (const [text, code, status] of steps) { + view.rerender(assistantMessage(registry, text, true)); + const fence = screen.getByTestId("stateful-fence"); + expect(fence.getAttribute("data-code")).toBe(code); + expect(fence.getAttribute("data-status")).toBe(status); + expect(fence.getAttribute("data-index")).toBe("0"); + expect(fence.textContent).toBe("clicks 1"); + } + expect( + view.container.querySelectorAll("[data-markdown-preview]"), + ).toHaveLength(2); + expect( + view.container + .querySelectorAll("[data-markdown-preview]")[0] + ?.contains(screen.getByTestId("stateful-fence")), + ).toBe(true); + + view.rerender( + assistantMessage( + registry, + "Intro\n\n```ui\n{ partial }\n```\n\nLater prose.\n\nMore prose.", + false, + ), + ); + const settled = screen.getByTestId("stateful-fence"); + expect(settled.textContent).toBe("clicks 1"); + expect(settled.getAttribute("data-status")).toBe("closed"); + expect(screen.getByText("More prose.")).toBeTruthy(); + expect(mounts).toBe(1); + }); + + it("numbers fences across the settled and streaming regions of one reply", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render( + assistantMessage( + registry, + "```ui\n{ settled }\n```\n\nMiddle prose.\n\n```ui\n{ tail", + true, + ), + ); + expect( + view.container.querySelectorAll("[data-markdown-preview]"), + ).toHaveLength(2); + expect(renderedFences()).toEqual([ + { code: "{ settled }", status: "closed", index: "0" }, + { code: "{ tail", status: "streaming", index: "1" }, + ]); + }); + + it("reports a fence the reply never closed as unclosed once streaming stops", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render(assistantMessage(registry, "```ui\n{ cut", true)); + expect(renderedFences()).toEqual([ + { code: "{ cut", status: "streaming", index: "0" }, + ]); + view.rerender(assistantMessage(registry, "```ui\n{ cut", false)); + expect(renderedFences()).toEqual([ + { code: "{ cut", status: "unclosed", index: "0" }, + ]); + }); + + it("removes a fence's component when the fence leaves the message", async () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + const view = render(assistantMessage(registry, "```ui\n{ gone }\n```")); + expect(screen.getAllByTestId("ui-fence")).toHaveLength(1); + view.rerender(assistantMessage(registry, "Plain prose only.")); + await waitFor(() => { + expect(screen.queryByTestId("ui-fence")).toBeNull(); + }); + expect( + view.container.querySelector("[data-code-fence-parking]"), + ).toBeNull(); + }); + + it("keeps claimed fences through the sanitized HTML pipeline", () => { + const registry = buildCodeFenceRendererRegistry([slot(UiFence)]); + render( + assistantMessage( + registry, + "
Morehidden
\n\n```ui\n
page
\n```", + ), + ); + return screen.findByTestId("ui-fence").then((fence) => { + expect(fence.getAttribute("data-code")).toBe("
page
"); + expect(fence.getAttribute("data-status")).toBe("closed"); + }); + }); + + it("isolates a crash to one fence and tries that fence again when its code changes", () => { + function FragileFence(props: ExperimentalCodeFenceRendererProps) { + if (props.code.includes("{ bad")) { + throw new Error("fence boom"); + } + return ; + } + const registry = buildCodeFenceRendererRegistry([slot(FragileFence)]); + const view = render( + assistantMessage(registry, "```ui\n{ good }\n```\n\n```ui\n{ bad", true), + ); + expect(renderedFences()).toEqual([ + { code: "{ good }", status: "closed", index: "0" }, + ]); + expect(view.container.textContent).toContain("{ bad"); + expect(view.container.textContent).not.toContain("crashed"); + expect( + view.container.querySelectorAll('button[aria-label="Wrap long lines"]'), + ).toHaveLength(1); + + view.rerender( + assistantMessage( + registry, + "```ui\n{ good }\n```\n\n```ui\n{ fixed }\n```", + true, + ), + ); + expect(renderedFences()).toEqual([ + { code: "{ good }", status: "closed", index: "0" }, + { code: "{ fixed }", status: "closed", index: "1" }, + ]); + expect( + view.container.querySelectorAll('button[aria-label="Wrap long lines"]'), + ).toHaveLength(0); + }); + + it("renders BB's code block for a language two plugins claim, from the slot store", () => { + function ZetaFence() { + return
; + } + setPluginSlotRegistrations( + "zeta", + makePluginRegistrationSet({ + codeFenceRenderers: [ + { id: "zeta-ui", languages: ["ui", "app"], component: ZetaFence }, + ], + }), + ); + setPluginSlotRegistrations( + "alpha", + makePluginRegistrationSet({ + codeFenceRenderers: [ + { id: "alpha-ui", languages: ["ui"], component: UiFence }, + ], + }), + ); + + const view = render( + + + , + ); + + expect(screen.queryByTestId("ui-fence")).toBeNull(); + expect(screen.getAllByTestId("zeta-fence")).toHaveLength(1); + expect(view.container.textContent).toContain("{ contested }"); + expect(console.warn).toHaveBeenCalledWith( + '[plugin] code fence language "ui" claimed by multiple plugins (alpha, zeta); rendering BB\'s code block', + ); + }); +}); + +describe("MarkdownPreview inside a code fence host", () => { + it.each([ + ["an empty closed fence", "```ui\n```", "closed"], + ["a closed fence with a blank body", "```ui\n\n```", "closed"], + ["a longer closing fence", "```ui\na\n`````", "closed"], + ["a shorter closing run", "````ui\na\n```", "unclosed"], + ["an over-indented closing run", "```ui\na\n ```", "unclosed"], + ["a trailing newline without a closing fence", "```ui\na\n", "unclosed"], + [ + "a closed fence in a nested list", + "- x\n\n - y\n\n ```ui\n a\n ```", + "closed", + ], + [ + "an open fence in a nested list", + "- x\n\n - y\n\n ```ui\n a\n", + "unclosed", + ], + ["a closed fence in a blockquote", "> ```ui\n> a\n> ```", "closed"], + ["an open fence in a blockquote", "> ```ui\n> a\n\nafter", "unclosed"], + ["a closed tilde fence", "~~~ui\na\n~~~", "closed"], + ])("derives the status from the source for %s", (_label, content, status) => { + render( + hostedMarkdown(buildCodeFenceRendererRegistry([slot(UiFence)]), content), + ); + expect(screen.getByTestId("ui-fence").getAttribute("data-status")).toBe( + status, + ); + }); + + it("renders BB's original code block through Original", () => { + function DelegatingFence({ Original }: ExperimentalCodeFenceRendererProps) { + return ( +
+ +
+ ); + } + render( + hostedMarkdown( + buildCodeFenceRendererRegistry([slot(DelegatingFence)]), + "```ui\n{ delegated }\n```", + ), + ); + const wrapper = screen.getByTestId("delegating-fence"); + expect(wrapper.textContent).toContain("{ delegated }"); + expect( + wrapper.querySelector('button[aria-label="Wrap long lines"]'), + ).not.toBeNull(); + }); + + it("leaves fences on BB's code block outside a host", () => { + const view = render(); + expect(screen.queryByTestId("ui-fence")).toBeNull(); + expect(view.container.textContent).toContain("{ plain }"); + }); +}); + +describe("buildCodeFenceRendererRegistry", () => { + it("drops a language two plugins claim, keeps the rest, and warns once", () => { + const warn = vi.fn(); + const alpha = slot(UiFence, { + pluginId: "alpha", + languages: ["ui", "app"], + }); + const zeta = slot(UiFence, { pluginId: "zeta", languages: ["ui"] }); + const registry = buildCodeFenceRendererRegistry([zeta, alpha], { warn }); + + expect(registry.has("ui")).toBe(false); + expect(registry.get("app")).toBe(alpha); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[plugin] code fence language "ui" claimed by multiple plugins (alpha, zeta); rendering BB\'s code block', + ); + }); +}); diff --git a/apps/app/src/components/ui/markdown-code-fence-renderers.tsx b/apps/app/src/components/ui/markdown-code-fence-renderers.tsx new file mode 100644 index 00000000000..ce28ce94e14 --- /dev/null +++ b/apps/app/src/components/ui/markdown-code-fence-renderers.tsx @@ -0,0 +1,410 @@ +import { + createContext, + memo, + useContext, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import type { Code, Root } from "mdast"; +import type {} from "mdast-util-to-hast"; +import type { + ExperimentalCodeFenceRendererProps, + ExperimentalCodeFenceStatus, +} from "@get-bb/plugin-sdk"; +import { visit } from "unist-util-visit"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount.js"; +import { PluginThreadPanelNavigationProvider } from "@/components/plugin/plugin-thread-panel-navigation.js"; +import { resolveCodeFenceRendererRegistry } from "@/lib/plugin-slot-resolvers.js"; +import type { PluginCodeFenceRendererSlot } from "@/lib/plugin-slots.js"; +import type { MarkdownMessageDirectiveOpenThreadPanel } from "./markdown-message-directives.js"; +import { + closesAnyIndentMarkdownFence, + parseMarkdownFenceStart, +} from "./markdown-prompt-blockquote-boundaries.js"; + +const CODE_FENCE_OFFSET_PROPERTY = "dataCodeFenceOffset"; +const CODE_FENCE_CLOSED_PROPERTY = "dataCodeFenceClosed"; +const MARKDOWN_CONTAINER_PREFIX_PATTERN = /^[ \t>]*/u; + +export type CodeFenceRendererRegistry = ReadonlyMap< + string, + PluginCodeFenceRendererSlot +>; + +export interface CodeFenceElementProps { + "data-code-fence-offset"?: string; + "data-code-fence-closed"?: string; +} + +interface RemarkCodeFenceRenderersFile { + value: unknown; +} + +export function buildCodeFenceRendererRegistry( + slots: readonly PluginCodeFenceRendererSlot[], + options?: { warn?: (message: string) => void }, +): CodeFenceRendererRegistry { + const warn = options?.warn ?? ((message: string) => console.warn(message)); + const registry = new Map(); + for (const [language, resolved] of resolveCodeFenceRendererRegistry(slots)) { + if (resolved.status === "collision") { + warn( + `[plugin] code fence language "${language}" claimed by multiple plugins (${resolved.pluginIds.join(", ")}); rendering BB's code block`, + ); + continue; + } + registry.set(language, resolved.slot); + } + return registry; +} + +function isClosedCodeFence(node: Code, markdownSource: string): boolean { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined) { + return false; + } + const lines = markdownSource.slice(start, end).split("\n"); + const fence = parseMarkdownFenceStart(lines[0] ?? ""); + const closingLine = lines.at(-1); + if (fence === null || lines.length < 2 || closingLine === undefined) { + return false; + } + return ( + closesAnyIndentMarkdownFence( + closingLine.replace(MARKDOWN_CONTAINER_PREFIX_PATTERN, ""), + fence, + ) && + (node.value === "" || node.value.split("\n").length === lines.length - 2) + ); +} + +export function remarkCodeFenceRenderers(args: { + registry: CodeFenceRendererRegistry; + sourceOffset: number; +}) { + const { registry, sourceOffset } = args; + return (tree: Root, file: RemarkCodeFenceRenderersFile): void => { + const markdownSource = + typeof file.value === "string" ? file.value : String(file.value ?? ""); + visit(tree, "code", (node) => { + const language = node.lang?.toLowerCase(); + const offset = node.position?.start.offset; + if ( + language === undefined || + offset === undefined || + !registry.has(language) + ) { + return; + } + node.data ??= {}; + node.data.hProperties = { + ...node.data.hProperties, + [CODE_FENCE_OFFSET_PROPERTY]: String(sourceOffset + offset), + [CODE_FENCE_CLOSED_PROPERTY]: String( + isClosedCodeFence(node, markdownSource), + ), + }; + }); + }; +} + +interface CodeFenceBlock { + closed: boolean; + code: string; + language: string; + original: ReactNode; +} + +interface CodeFenceEntry { + block: CodeFenceBlock | null; + container: HTMLDivElement; + offset: number; + placeholder: HTMLElement | null; +} + +interface CodeFenceHost { + registry: CodeFenceRendererRegistry; + attach: (offset: number, placeholder: HTMLElement) => void; + detach: (offset: number, placeholder: HTMLElement) => void; + update: (offset: number, block: CodeFenceBlock) => void; +} + +const CodeFenceHostContext = createContext(null); + +const CodeFenceOriginalContext = createContext(null); + +function CodeFenceOriginal() { + return <>{useContext(CodeFenceOriginalContext)}; +} + +function placeCodeFenceContainer( + parent: HTMLElement, + container: HTMLDivElement, +): void { + if (container.parentNode === parent) { + return; + } + if ( + container.isConnected && + parent.isConnected && + typeof parent.moveBefore === "function" + ) { + try { + parent.moveBefore(container, null); + return; + } catch { + parent.appendChild(container); + return; + } + } + parent.appendChild(container); +} + +export function useCodeFenceLanguages(): CodeFenceRendererRegistry | null { + const registry = useContext(CodeFenceHostContext)?.registry; + return registry === undefined || registry.size === 0 ? null : registry; +} + +interface CodeFenceRendererHostProps { + children: ReactNode; + message: ExperimentalCodeFenceRendererProps["message"]; + openThreadPanel: MarkdownMessageDirectiveOpenThreadPanel | null; + registry: CodeFenceRendererRegistry; + streaming: boolean; +} + +export function CodeFenceRendererHost({ + children, + message, + openThreadPanel, + registry, + streaming, +}: CodeFenceRendererHostProps) { + const [entries] = useState(() => new Map()); + const parkingRef = useRef(null); + const [, rerender] = useReducer((revision: number) => revision + 1, 0); + + const host = useMemo( + (): CodeFenceHost => ({ + registry, + attach(offset, placeholder) { + let entry = entries.get(offset); + if (entry === undefined) { + entry = { + block: null, + container: document.createElement("div"), + offset, + placeholder, + }; + entries.set(offset, entry); + } else { + entry.placeholder = placeholder; + } + placeCodeFenceContainer(placeholder, entry.container); + }, + detach(offset, placeholder) { + const entry = entries.get(offset); + if (entry === undefined || entry.placeholder !== placeholder) { + return; + } + entry.placeholder = null; + if (parkingRef.current !== null) { + placeCodeFenceContainer(parkingRef.current, entry.container); + } + queueMicrotask(() => { + if (entry.placeholder !== null || entries.get(offset) !== entry) { + return; + } + entry.container.remove(); + entries.delete(offset); + rerender(); + }); + }, + update(offset, block) { + const entry = entries.get(offset); + if (entry === undefined) { + return; + } + const previous = entry.block; + entry.block = block; + if ( + previous === null || + previous.code !== block.code || + previous.closed !== block.closed || + previous.language !== block.language + ) { + rerender(); + } + }, + }), + [entries, registry], + ); + + const ordered = [...entries.values()].sort( + (left, right) => left.offset - right.offset, + ); + const ordinals = new Map(); + const mounts: ReactNode[] = []; + for (const entry of ordered) { + const { block } = entry; + if (block === null) { + continue; + } + const index = ordinals.get(block.language) ?? 0; + ordinals.set(block.language, index + 1); + const slot = registry.get(block.language); + mounts.push( + createPortal( + + {slot === undefined ? ( + + ) : ( + + )} + , + entry.container, + String(entry.offset), + ), + ); + } + + return ( + + {children} + {mounts.length === 0 ? null : ( +