From 1074d0553c15540c794f2d922a9b710f55cd27df Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 09:15:42 +0000 Subject: [PATCH 1/3] feat(agentation): enrich plugin surface context --- .changeset/surface-aware-agentation.md | 5 + plugins/agentation/README.md | 2 +- plugins/agentation/lib/afs.ts | 2 +- plugins/agentation/lib/plugin-ui-surface.ts | 298 ++++++++++++++++++ plugins/agentation/lib/toolbar.ts | 31 +- .../agentation/test/plugin-ui-surface.test.ts | 246 +++++++++++++++ 6 files changed, 553 insertions(+), 31 deletions(-) create mode 100644 .changeset/surface-aware-agentation.md create mode 100644 plugins/agentation/lib/plugin-ui-surface.ts create mode 100644 plugins/agentation/test/plugin-ui-surface.test.ts diff --git a/.changeset/surface-aware-agentation.md b/.changeset/surface-aware-agentation.md new file mode 100644 index 0000000..6e5a94e --- /dev/null +++ b/.changeset/surface-aware-agentation.md @@ -0,0 +1,5 @@ +--- +"@smsunarto/bb-plugin-agentation": patch +--- + +Enrich annotations with the exact public bb plugin UI surface that owns the selected element, including component slots, composer contributions, and host-rendered plugin actions. diff --git a/plugins/agentation/README.md b/plugins/agentation/README.md index 635b597..e7067a5 100644 --- a/plugins/agentation/README.md +++ b/plugins/agentation/README.md @@ -96,7 +96,7 @@ where to look before it starts grepping. | ------------------------------ | ---------------------------------------------- | | `bb.route` | The bb route the annotation was taken on. | | `bb.pluginId` | Owning plugin, or `null` for the bb app shell. | -| `bb.surface` | `navPanel`, `inline`, or `overlay`. | +| `bb.surface` | Public SDK surface such as `navPanel`, `composer.banners`, `experimental_threadList`, or `threadPanelAction.component`; `inline` / `overlay` for trusted custom content. | | `bb.threadId` / `bb.projectId` | Source context resolved from the route. | ### Commands diff --git a/plugins/agentation/lib/afs.ts b/plugins/agentation/lib/afs.ts index a229521..539e6d0 100644 --- a/plugins/agentation/lib/afs.ts +++ b/plugins/agentation/lib/afs.ts @@ -48,7 +48,7 @@ export const bbContextSchema = z.object({ route: z.string(), /** Owning plugin id when the element sat inside a plugin surface. */ pluginId: z.string().nullable(), - /** Plugin surface kind (`navPanel`, `threadPanel`, …) when detectable. */ + /** Public plugin UI API (`navPanel`, `composer.banners`, …) when detectable. */ surface: z.string().nullable(), threadId: z.string().nullable(), projectId: z.string().nullable(), diff --git a/plugins/agentation/lib/plugin-ui-surface.ts b/plugins/agentation/lib/plugin-ui-surface.ts new file mode 100644 index 0000000..8f23b19 --- /dev/null +++ b/plugins/agentation/lib/plugin-ui-surface.ts @@ -0,0 +1,298 @@ +// Attribute an element to the public bb plugin UI API that put it on screen. +// +// bb deliberately exposes only plugin ownership in the DOM. The exact slot +// identity lives on the React boundary that contains every plugin component as +// `pluginId`, `slotKind`, and `slotId`. Agentation already walks React fibers to +// record component paths, so reading that boundary here uses the same +// best-effort diagnostic seam and lets annotations name the SDK surface an +// agent should inspect instead of the old catch-all `inline` label. + +import { panelPluginIdFromRoute } from "./route.ts"; + +export interface PluginUiSurfaceContext { + pluginId: string | null; + surface: string | null; +} + +/** + * bb's renderer names its component boundaries without experimental prefixes + * or registration member names. Keep the translation beside the annotation + * code so the stored value matches the public SDK API shown to plugin authors. + */ +export const PUBLIC_SURFACE_BY_SLOT_KIND = { + composerAction: "composer.actions", + composerBanner: "composer.banners", + composerPlusMenuItem: "composer.plusMenu", + fileOpener: "fileOpener", + homepageSection: "homepageSection", + messageDirective: "messageDirective", + navPanel: "navPanel", + navPanelFixedTab: "navPanel.experimental_fixedTabs", + navPanelHeaderContent: "navPanel.headerContent", + navPanelSidebarAccessory: "navPanel.experimental_sidebarAccessory", + newThreadPanelAction: "experimental_newThreadPanelAction.component", + pendingInteraction: "pendingInteraction", + providerIcon: "experimental_providerIcon", + settingsSection: "settingsSection", + threadHeaderAction: "experimental_threadHeaderAction", + threadList: "experimental_threadList", + threadPanelAction: "threadPanelAction.component", +} as const satisfies Readonly>; + +type ReactFiber = { + memoizedProps?: unknown; + return?: ReactFiber | null; +}; + +type PluginRecord = { + pluginId: string; + id?: string; + key?: string; + label?: string; + title?: string; +}; + +const REACT_FIBER_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"] as const; +const MAX_FIBER_DEPTH = 80; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function fiberFromElement(element: Element): ReactFiber | null { + let node: Element | null = element; + while (node) { + const key = Object.keys(node).find((candidate) => + REACT_FIBER_PREFIXES.some((prefix) => candidate.startsWith(prefix)), + ); + if (key) { + const fiber = (node as unknown as Record)[key]; + if (typeof fiber === "object" && fiber !== null) return fiber as ReactFiber; + } + node = node.parentElement; + } + return null; +} + +function publicSurface(slotKind: string): string { + return PUBLIC_SURFACE_BY_SLOT_KIND[ + slotKind as keyof typeof PUBLIC_SURFACE_BY_SLOT_KIND + ] ?? slotKind; +} + +/** The nearest bb plugin component boundary in this element's React ancestry. */ +function componentBoundaryFor(element: Element): PluginUiSurfaceContext | null { + let fiber = fiberFromElement(element); + let depth = 0; + while (fiber && depth < MAX_FIBER_DEPTH) { + const props = asRecord(fiber.memoizedProps); + const pluginId = nonEmptyString(props?.pluginId); + const slotKind = nonEmptyString(props?.slotKind); + if (pluginId && slotKind) { + return { pluginId, surface: publicSurface(slotKind) }; + } + fiber = fiber.return ?? null; + depth += 1; + } + return null; +} + +function pluginRecord(value: unknown): PluginRecord | null { + const record = asRecord(value); + const pluginId = nonEmptyString(record?.pluginId); + if (!pluginId) return null; + return { + pluginId, + ...(nonEmptyString(record?.id) ? { id: String(record?.id) } : {}), + ...(nonEmptyString(record?.key) ? { key: String(record?.key) } : {}), + ...(nonEmptyString(record?.label) ? { label: String(record?.label) } : {}), + ...(nonEmptyString(record?.title) ? { title: String(record?.title) } : {}), + }; +} + +/** + * Host-rendered registrations do not get a plugin component boundary. Their + * descriptor remains in an ancestor's props, so collect only direct records + * and arrays from that ancestry; walking arbitrary object graphs would enter + * React elements and application state. + */ +function pluginRecordsFromFiber(element: Element): PluginRecord[] { + const records: PluginRecord[] = []; + let fiber = fiberFromElement(element); + let depth = 0; + while (fiber && depth < MAX_FIBER_DEPTH) { + const props = asRecord(fiber.memoizedProps); + if (props) { + for (const value of Object.values(props)) { + if (Array.isArray(value)) { + for (const item of value) { + const candidate = pluginRecord(item); + if (candidate) records.push(candidate); + } + continue; + } + const candidate = pluginRecord(value); + if (candidate) records.push(candidate); + } + } + fiber = fiber.return ?? null; + depth += 1; + } + return records; +} + +function targetLabels(element: Element): Set { + const labels = new Set(); + let node: Element | null = element; + for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) { + for (const value of [ + node.getAttribute("aria-label"), + node.getAttribute("title"), + node.textContent?.trim(), + ]) { + if (value) labels.add(value); + } + if (node.matches("button, [role='button'], [role='menuitem']")) break; + } + return labels; +} + +function labelMatches(record: PluginRecord, labels: Set): boolean { + return [record.label, record.title].some( + (candidate) => candidate !== undefined && labels.has(candidate), + ); +} + +function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null { + const labels = targetLabels(element); + const records = pluginRecordsFromFiber(element); + + const footer = element.closest( + "[data-testid^='plugin-sidebar-footer-action-']", + ); + const footerTestId = footer?.getAttribute("data-testid") ?? null; + if (footerTestId) { + const action = records.find( + (record) => + record.id !== undefined && + footerTestId === `plugin-sidebar-footer-action-${record.pluginId}-${record.id}`, + ); + if (action) return { pluginId: action.pluginId, surface: "sidebarFooterAction" }; + } + + const panelAction = records.find( + (record) => + record.id?.startsWith(`plugin-action:${record.pluginId}:`) === true && + labelMatches(record, labels), + ); + if (panelAction) { + return { pluginId: panelAction.pluginId, surface: "threadPanelAction.run" }; + } + + const newThreadPanelAction = records.find( + (record) => + record.id?.startsWith(`plugin-new-thread-action:${record.pluginId}:`) === true && + labelMatches(record, labels), + ); + if (newThreadPanelAction) { + return { + pluginId: newThreadPanelAction.pluginId, + surface: "experimental_newThreadPanelAction.run", + }; + } + + // Registered message actions retain a `//` key + // in both the message bar and its portalled selected-text menu. + // Consumer-supplied ThreadChat actions use `consumer/…` keys and correctly + // remain attributed to their enclosing slot. + const messageAction = records.find( + (record) => + record.key?.startsWith(`${record.pluginId}/`) === true && + record.key.split("/").length === 3 && + labelMatches(record, labels), + ); + if (messageAction) { + return { pluginId: messageAction.pluginId, surface: "messageAction" }; + } + + return null; +} + +function pluginIdFromAssetUrl(rawUrl: string | null): string | null { + if (!rawUrl) return null; + const match = /\/api\/v1\/plugins\/([^/]+)\/assets(?:[/?#]|$)/.exec(rawUrl); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +function navPanelRowFor(element: Element): PluginUiSurfaceContext | null { + if (!element.closest("[data-testid='plugin-nav-sidebar-items']")) return null; + const row = + element.closest(".bb-sidebar-hover-actions-row") ?? + element.closest("button, a, [role='button']") ?? + element; + const icon = row.querySelector("[data-plugin-icon-asset]"); + const pluginId = pluginIdFromAssetUrl(icon?.getAttribute("data-plugin-icon-asset") ?? null); + return pluginId ? { pluginId, surface: "navPanel" } : null; +} + +/** + * Which bb plugin UI surface an element belongs to. + * + * Exact SDK registrations win. The final `overlay` / `navPanel` / `inline` + * fallbacks preserve attribution for hand-written trusted content and future + * bb surfaces that do not yet expose a component boundary. + */ +export function pluginUiSurfaceFor( + element: Element | null, + route: string, +): PluginUiSurfaceContext { + if (!element) return { pluginId: null, surface: null }; + + const boundary = componentBoundaryFor(element); + if (boundary) return boundary; + + const richTextDecoration = element.closest("[data-bb-plugin-decoration]"); + const richTextPluginId = richTextDecoration?.getAttribute("data-bb-plugin-decoration") ?? null; + if (richTextPluginId) { + return { pluginId: richTextPluginId, surface: "composer.richText" }; + } + + const composerAction = element.closest("[data-plugin-composer-action-plugin]"); + const composerActionPluginId = + composerAction?.getAttribute("data-plugin-composer-action-plugin") ?? null; + if (composerActionPluginId) { + return { pluginId: composerActionPluginId, surface: "composer.actions" }; + } + + const hostAction = hostRenderedActionFor(element); + if (hostAction) return hostAction; + + const navRow = navPanelRowFor(element); + if (navRow) return navRow; + + const owner = element.closest("[data-bb-plugin]"); + const pluginId = owner?.getAttribute("data-bb-plugin") ?? null; + if (!pluginId) return { pluginId: null, surface: null }; + + if (element.closest("[data-testid='app-page-header-content-row']")) { + return { pluginId, surface: "navPanel.headerContent" }; + } + if (element.closest("[data-bb-portaled-overlay]")) { + return { pluginId, surface: "overlay" }; + } + if (panelPluginIdFromRoute(route) === pluginId) { + return { pluginId, surface: "navPanel" }; + } + return { pluginId, surface: "inline" }; +} diff --git a/plugins/agentation/lib/toolbar.ts b/plugins/agentation/lib/toolbar.ts index e4921d2..595295f 100644 --- a/plugins/agentation/lib/toolbar.ts +++ b/plugins/agentation/lib/toolbar.ts @@ -34,10 +34,10 @@ import { selectOrphans, withoutBundleSource } from "./annotation-hygiene.ts"; import { createRpcClient } from "./plugin-rpc.ts"; import { labelForRoute, - panelPluginIdFromRoute, projectIdFromRoute, threadIdFromRoute, } from "./route.ts"; +import { pluginUiSurfaceFor } from "./plugin-ui-surface.ts"; import { seedAgentationThemeDefault } from "./theme.ts"; import { createCoalescingQueue, @@ -124,33 +124,6 @@ function pageMeta(): PageMeta { }; } -/** - * Which bb surface an element belongs to. - * - * bb wraps every plugin-rendered subtree in `
`, including portalled overlays, so the nearest such - * ancestor is an exact answer to "whose code draws this?" — the difference - * between feedback an agent can act on and a selector with no home. - */ -function surfaceFor( - element: Element | null, - route: string, -): { pluginId: string | null; surface: string | null } { - if (!element) return { pluginId: null, surface: null }; - - const owner = element.closest("[data-bb-plugin]"); - const pluginId = owner?.getAttribute("data-bb-plugin") ?? null; - if (!pluginId) return { pluginId: null, surface: null }; - - if (element.closest("[data-bb-portaled-overlay]")) { - return { pluginId, surface: "overlay" }; - } - if (panelPluginIdFromRoute(route) === pluginId) { - return { pluginId, surface: "navPanel" }; - } - return { pluginId, surface: "inline" }; -} - /** Ignore clicks on the toolbar's own chrome when tracking the last target. */ function isToolbarChrome(element: Element | null): boolean { return Boolean( @@ -247,7 +220,7 @@ export async function mountAnnotationToolbar( let stream: EventSource | null = null; function contextForNewAnnotation(): BbContext { - const { pluginId, surface } = surfaceFor(lastTarget, meta.route); + const { pluginId, surface } = pluginUiSurfaceFor(lastTarget, meta.route); return { route: meta.route, pluginId, diff --git a/plugins/agentation/test/plugin-ui-surface.test.ts b/plugins/agentation/test/plugin-ui-surface.test.ts new file mode 100644 index 0000000..cdae2c3 --- /dev/null +++ b/plugins/agentation/test/plugin-ui-surface.test.ts @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + pluginUiSurfaceFor, + PUBLIC_SURFACE_BY_SLOT_KIND, +} from "../lib/plugin-ui-surface.ts"; + +type Fiber = { + memoizedProps?: unknown; + return?: Fiber | null; +}; + +type FakeElementOptions = { + attributes?: Record; + closest?: Record; + matches?: string[]; + parent?: FakeElement | null; + queries?: Record; + text?: string; +}; + +class FakeElement { + readonly attributes: Record; + readonly closestResults: Record; + readonly matchResults: Set; + readonly parentElement: FakeElement | null; + readonly queryResults: Record; + readonly textContent: string; + + constructor(options: FakeElementOptions = {}) { + this.attributes = options.attributes ?? {}; + this.closestResults = options.closest ?? {}; + this.matchResults = new Set(options.matches ?? []); + this.parentElement = options.parent ?? null; + this.queryResults = options.queries ?? {}; + this.textContent = options.text ?? ""; + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } + + closest(selector: string): FakeElement | null { + return this.closestResults[selector] ?? null; + } + + matches(selector: string): boolean { + return this.matchResults.has(selector); + } + + querySelector(selector: string): FakeElement | null { + return this.queryResults[selector] ?? null; + } +} + +function asElement(element: FakeElement): Element { + return element as unknown as Element; +} + +function withFiber(element: FakeElement, fiber: Fiber): FakeElement { + Object.defineProperty(element, "__reactFiber$agentation-test", { + configurable: true, + enumerable: true, + value: fiber, + }); + return element; +} + +const expectedBoundarySurfaces = { + composerAction: "composer.actions", + composerBanner: "composer.banners", + composerPlusMenuItem: "composer.plusMenu", + fileOpener: "fileOpener", + homepageSection: "homepageSection", + messageDirective: "messageDirective", + navPanel: "navPanel", + navPanelFixedTab: "navPanel.experimental_fixedTabs", + navPanelHeaderContent: "navPanel.headerContent", + navPanelSidebarAccessory: "navPanel.experimental_sidebarAccessory", + newThreadPanelAction: "experimental_newThreadPanelAction.component", + pendingInteraction: "pendingInteraction", + providerIcon: "experimental_providerIcon", + settingsSection: "settingsSection", + threadHeaderAction: "experimental_threadHeaderAction", + threadList: "experimental_threadList", + threadPanelAction: "threadPanelAction.component", +}; + +test("every bb plugin component boundary maps to its public SDK surface", () => { + assert.deepEqual(PUBLIC_SURFACE_BY_SLOT_KIND, expectedBoundarySurfaces); + + for (const [slotKind, surface] of Object.entries(expectedBoundarySurfaces)) { + const target = withFiber(new FakeElement(), { + return: { + memoizedProps: { pluginId: "example-plugin", slotId: "example", slotKind }, + }, + }); + assert.deepEqual(pluginUiSurfaceFor(asElement(target), "/"), { + pluginId: "example-plugin", + surface, + }); + } +}); + +test("an unknown future component boundary remains useful without a code update", () => { + const target = withFiber(new FakeElement(), { + memoizedProps: { + pluginId: "future-plugin", + slotId: "preview", + slotKind: "futurePreview", + }, + }); + + assert.deepEqual(pluginUiSurfaceFor(asElement(target), "/"), { + pluginId: "future-plugin", + surface: "futurePreview", + }); +}); + +test("composer paint and host-rendered actions carry their owning plugin", () => { + const richText = new FakeElement({ + attributes: { "data-bb-plugin-decoration": "amp" }, + }); + richText.closestResults["[data-bb-plugin-decoration]"] = richText; + assert.deepEqual(pluginUiSurfaceFor(asElement(richText), "/threads/thr_1"), { + pluginId: "amp", + surface: "composer.richText", + }); + + const composerAction = new FakeElement({ + attributes: { "data-plugin-composer-action-plugin": "review" }, + }); + composerAction.closestResults["[data-plugin-composer-action-plugin]"] = composerAction; + assert.deepEqual(pluginUiSurfaceFor(asElement(composerAction), "/threads/thr_1"), { + pluginId: "review", + surface: "composer.actions", + }); +}); + +test("host-rendered action descriptors distinguish launchers, messages, and the footer", () => { + const cases = [ + { + element: withFiber( + new FakeElement({ attributes: { "aria-label": "GitHub Stack" } }), + { + memoizedProps: { + actions: [ + { + id: "plugin-action:gh-stack:stack", + pluginId: "gh-stack", + title: "GitHub Stack", + }, + ], + }, + }, + ), + expected: { pluginId: "gh-stack", surface: "threadPanelAction.run" }, + }, + { + element: withFiber( + new FakeElement({ attributes: { "aria-label": "Plan work" } }), + { + memoizedProps: { + actions: [ + { + id: "plugin-new-thread-action:planner:plan", + pluginId: "planner", + title: "Plan work", + }, + ], + }, + }, + ), + expected: { + pluginId: "planner", + surface: "experimental_newThreadPanelAction.run", + }, + }, + { + element: withFiber( + new FakeElement({ attributes: { "aria-label": "Send to inbox" } }), + { + memoizedProps: { + pluginActions: [ + { + key: "support/send-to-inbox/3", + label: "Send to inbox", + pluginId: "support", + }, + ], + }, + }, + ), + expected: { pluginId: "support", surface: "messageAction" }, + }, + ] as const; + + for (const entry of cases) { + assert.deepEqual(pluginUiSurfaceFor(asElement(entry.element), "/threads/thr_1"), entry.expected); + } + + const footer = withFiber( + new FakeElement({ + attributes: { + "aria-label": "Remote access", + "data-testid": "plugin-sidebar-footer-action-connect-remote-access", + }, + }), + { + memoizedProps: { + actions: [ + { id: "remote-access", pluginId: "connect", title: "Remote access" }, + ], + }, + }, + ); + footer.closestResults["[data-testid^='plugin-sidebar-footer-action-']"] = footer; + assert.deepEqual(pluginUiSurfaceFor(asElement(footer), "/"), { + pluginId: "connect", + surface: "sidebarFooterAction", + }); +}); + +test("manual plugin roots retain precise header and route fallbacks", () => { + const owner = new FakeElement({ attributes: { "data-bb-plugin": "notes" } }); + const header = new FakeElement(); + header.closestResults["[data-bb-plugin]"] = owner; + header.closestResults["[data-testid='app-page-header-content-row']"] = header; + assert.deepEqual(pluginUiSurfaceFor(asElement(header), "/plugins/notes/notes"), { + pluginId: "notes", + surface: "navPanel.headerContent", + }); + + const body = new FakeElement(); + body.closestResults["[data-bb-plugin]"] = owner; + assert.deepEqual(pluginUiSurfaceFor(asElement(body), "/plugins/notes/notes"), { + pluginId: "notes", + surface: "navPanel", + }); + + assert.deepEqual(pluginUiSurfaceFor(null, "/"), { + pluginId: null, + surface: null, + }); +}); From 81883f5621aea6f8593f2bfb28814ab432fee77f Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 20:50:06 +0000 Subject: [PATCH 2/3] fix(agentation): mount toolbar outside DOM guard --- .changeset/surface-aware-agentation.md | 2 +- plugins/agentation/lib/toolbar.ts | 43 +++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.changeset/surface-aware-agentation.md b/.changeset/surface-aware-agentation.md index 6e5a94e..e972d32 100644 --- a/.changeset/surface-aware-agentation.md +++ b/.changeset/surface-aware-agentation.md @@ -2,4 +2,4 @@ "@smsunarto/bb-plugin-agentation": patch --- -Enrich annotations with the exact public bb plugin UI surface that owns the selected element, including component slots, composer contributions, and host-rendered plugin actions. +Enrich annotations with the exact public bb plugin UI surface that owns the selected element, including component slots, composer contributions, and host-rendered plugin actions. Keep the global React toolbar compatible with bb's foreign-DOM mutation guard. diff --git a/plugins/agentation/lib/toolbar.ts b/plugins/agentation/lib/toolbar.ts index 595295f..a7f85fb 100644 --- a/plugins/agentation/lib/toolbar.ts +++ b/plugins/agentation/lib/toolbar.ts @@ -174,7 +174,48 @@ function saveSynced(route: string, ids: Iterable): void { } } -export async function mountAnnotationToolbar( +/** + * bb tracks plugin content-script callbacks while they run so an imperative + * script cannot move nodes React already owns. Agentation deliberately owns a + * separate React root and portal, so start that root on the next animation + * frame, after the content-script callback has returned to the host. React's + * own DOM work then runs outside the foreign-mutation guard while every event, + * timer, and disposer remains owned by this plugin instance. + */ +export function mountAnnotationToolbar( + context: PluginContentScriptContext, +): PluginContentScriptDisposer { + let disposed = false; + let frame: number | null = requestAnimationFrame(() => { + frame = null; + void mountAnnotationToolbarRoot(context) + .then((disposeRoot) => { + if (disposed) { + void disposeRoot(); + return undefined; + } + rootDisposer = disposeRoot; + return undefined; + }) + .catch((error: unknown) => { + console.warn("[agentation] Could not mount annotation toolbar:", error); + }); + }); + let rootDisposer: PluginContentScriptDisposer | null = null; + + return () => { + disposed = true; + if (frame !== null) { + cancelAnimationFrame(frame); + frame = null; + } + const disposeRoot = rootDisposer; + rootDisposer = null; + return disposeRoot?.(); + }; +} + +async function mountAnnotationToolbarRoot( context: PluginContentScriptContext, ): Promise { const rpc = createRpcClient(context.pluginId); From 5c164fcae81d82b3b3e5f32784436b227a4464b8 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 21:23:03 +0000 Subject: [PATCH 3/3] feat(agentation): add actionable prompt locations --- .changeset/surface-aware-agentation.md | 2 +- plugins/agentation/README.md | 7 +- plugins/agentation/lib/afs.ts | 2 + plugins/agentation/lib/markdown.ts | 21 ++- .../agentation/lib/plugin-ui-surface-map.ts | 137 ++++++++++++++++++ plugins/agentation/lib/plugin-ui-surface.ts | 81 ++++++----- plugins/agentation/lib/toolbar.ts | 3 +- plugins/agentation/server.ts | 4 +- plugins/agentation/skills/agentation/SKILL.md | 26 ++-- .../agentation/test/plugin-ui-surface.test.ts | 21 ++- plugins/agentation/test/rendering.test.ts | 32 +++- plugins/agentation/test/staging.test.ts | 1 + plugins/agentation/test/store.test.ts | 5 +- 13 files changed, 277 insertions(+), 65 deletions(-) create mode 100644 plugins/agentation/lib/plugin-ui-surface-map.ts diff --git a/.changeset/surface-aware-agentation.md b/.changeset/surface-aware-agentation.md index e972d32..ddf25ec 100644 --- a/.changeset/surface-aware-agentation.md +++ b/.changeset/surface-aware-agentation.md @@ -2,4 +2,4 @@ "@smsunarto/bb-plugin-agentation": patch --- -Enrich annotations with the exact public bb plugin UI surface that owns the selected element, including component slots, composer contributions, and host-rendered plugin actions. Keep the global React toolbar compatible with bb's foreign-DOM mutation guard. +Enrich annotations with the exact public bb plugin UI surface and registration id that own the selected element, including component slots, composer contributions, and host-rendered plugin actions. Render source-oriented prompt guidance that points agents to the matching SDK registration in the plugin frontend. Keep the global React toolbar compatible with bb's foreign-DOM mutation guard. diff --git a/plugins/agentation/README.md b/plugins/agentation/README.md index e7067a5..748a3ef 100644 --- a/plugins/agentation/README.md +++ b/plugins/agentation/README.md @@ -20,9 +20,9 @@ Agentation puts a visual feedback toolbar over the whole bb interface — the app shell and any surface another plugin drew. Click an element, write what should change, and the annotation records the DOM selector, the React component path, -the bb route, and the plugin that owns the element. An agent reads that, fixes -the code, and resolves the annotation. The marker disappears from every open bb -window. +the bb route, the plugin that owns the element, and its public SDK UI +registration. An agent reads that, fixes the code, and resolves the annotation. +The marker disappears from every open bb window. It is built on [Agentation](https://www.agentation.com) and its [AFS 1.1](https://www.agentation.com/schema) annotation format. Agent tools are @@ -97,6 +97,7 @@ where to look before it starts grepping. | `bb.route` | The bb route the annotation was taken on. | | `bb.pluginId` | Owning plugin, or `null` for the bb app shell. | | `bb.surface` | Public SDK surface such as `navPanel`, `composer.banners`, `experimental_threadList`, or `threadPanelAction.component`; `inline` / `overlay` for trusted custom content. | +| `bb.surfaceId` | Registration/item id exposed by the surface, such as `inbox`; omitted on older annotations or when bb does not expose one. | | `bb.threadId` / `bb.projectId` | Source context resolved from the route. | ### Commands diff --git a/plugins/agentation/lib/afs.ts b/plugins/agentation/lib/afs.ts index 539e6d0..dc9bd00 100644 --- a/plugins/agentation/lib/afs.ts +++ b/plugins/agentation/lib/afs.ts @@ -50,6 +50,8 @@ export const bbContextSchema = z.object({ pluginId: z.string().nullable(), /** Public plugin UI API (`navPanel`, `composer.banners`, …) when detectable. */ surface: z.string().nullable(), + /** Registration/item id exposed by the plugin UI boundary, e.g. `inbox`. */ + surfaceId: z.string().nullable().optional(), threadId: z.string().nullable(), projectId: z.string().nullable(), /** Human label for the route, used in listings. */ diff --git a/plugins/agentation/lib/markdown.ts b/plugins/agentation/lib/markdown.ts index 0971419..e408d1b 100644 --- a/plugins/agentation/lib/markdown.ts +++ b/plugins/agentation/lib/markdown.ts @@ -7,16 +7,26 @@ // the same wherever an agent meets it. import type { Session, StoredAnnotation } from "./afs.ts"; +import { pluginUiSurfacePromptContext } from "./plugin-ui-surface-map.ts"; function line(label: string, value: string | null | undefined): string { return value ? `**${label}:** ${value}\n` : ""; } function locationOf(annotation: StoredAnnotation): string { - const { pluginId, surface, route } = annotation.bb; - if (!pluginId) return `bb app shell · ${route}`; - const surfaceLabel = surface ? ` (${surface})` : ""; - return `plugin \`${pluginId}\`${surfaceLabel} · ${route}`; + const { pluginId, route, routeLabel } = annotation.bb; + const routeContext = routeLabel ? ` \`${route}\` (${routeLabel})` : ` \`${route}\``; + if (!pluginId) return `bb app shell · route${routeContext}`; + return `plugin \`${pluginId}\` · route${routeContext}`; +} + +function pluginUiOf(annotation: StoredAnnotation): string | null { + const { pluginId, surface, surfaceId } = annotation.bb; + if (!pluginId || !surface) return null; + + const context = pluginUiSurfacePromptContext(surface); + const registrationId = surfaceId ? ` · registration \`${surfaceId}\`` : ""; + return `\`${context.registration}\`${registrationId} — ${context.role}. Start at this registration in plugin \`${pluginId}\`'s \`app.tsx\`, then follow its component or run handler.`; } function describeKind(annotation: StoredAnnotation): string | null { @@ -42,6 +52,7 @@ export function renderAnnotation(annotation: StoredAnnotation, index?: number): let out = `${heading}\n`; out += line("Where", locationOf(annotation)); + out += line("Plugin UI", pluginUiOf(annotation)); out += line("Selector", `\`${annotation.elementPath}\``); out += line("React", annotation.reactComponents); out += line("Source", annotation.sourceFile); @@ -88,7 +99,7 @@ export function renderAnnotations( const sessionsById = new Map((options.sessions ?? []).map((session) => [session.id, session])); let out = `## ${options.title ?? "bb UI feedback"}\n\n`; - out += `${annotations.length} annotation${annotations.length === 1 ? "" : "s"} across ${bySession.size} page${bySession.size === 1 ? "" : "s"}. Element selectors are live bb DOM paths — pair them with the owning plugin or the bb app source to find the code.\n`; + out += `${annotations.length} annotation${annotations.length === 1 ? "" : "s"} across ${bySession.size} page${bySession.size === 1 ? "" : "s"}. For plugin UI, start from the named SDK registration in the owning plugin's \`app.tsx\`, then use the selector and React path to narrow the rendered component.\n`; for (const [sessionId, sessionAnnotations] of bySession) { const session = sessionsById.get(sessionId); diff --git a/plugins/agentation/lib/plugin-ui-surface-map.ts b/plugins/agentation/lib/plugin-ui-surface-map.ts new file mode 100644 index 0000000..fd7493c --- /dev/null +++ b/plugins/agentation/lib/plugin-ui-surface-map.ts @@ -0,0 +1,137 @@ +// Public bb plugin UI registrations and the source-level hints an agent needs +// to turn a captured surface name into a useful code search. + +/** Translate bb's internal component-boundary names to the public SDK API. */ +export const PUBLIC_SURFACE_BY_SLOT_KIND = { + composerAction: "composer.actions", + composerBanner: "composer.banners", + composerPlusMenuItem: "composer.plusMenu", + fileOpener: "fileOpener", + homepageSection: "homepageSection", + messageDirective: "messageDirective", + navPanel: "navPanel", + navPanelFixedTab: "navPanel.experimental_fixedTabs", + navPanelHeaderContent: "navPanel.headerContent", + navPanelSidebarAccessory: "navPanel.experimental_sidebarAccessory", + newThreadPanelAction: "experimental_newThreadPanelAction.component", + pendingInteraction: "pendingInteraction", + providerIcon: "experimental_providerIcon", + settingsSection: "settingsSection", + threadHeaderAction: "experimental_threadHeaderAction", + threadList: "experimental_threadList", + threadPanelAction: "threadPanelAction.component", +} as const satisfies Readonly>; + +export interface PluginUiSurfacePromptContext { + registration: string; + role: string; +} + +const PROMPT_CONTEXT_BY_SURFACE = { + "composer.actions": { + registration: "app.composer.customize({ actions })", + role: "a plugin component rendered in the composer action row", + }, + "composer.banners": { + registration: "app.composer.customize({ banners })", + role: "a plugin component rendered above the composer", + }, + "composer.plusMenu": { + registration: "app.composer.customize({ plusMenu })", + role: "a host-rendered plugin item in the composer's plus menu", + }, + "composer.richText": { + registration: "app.composer.customize({ richText })", + role: "plugin-owned paint or behavior applied to composer text", + }, + fileOpener: { + registration: "app.slots.fileOpener", + role: "a plugin component rendering an opened file panel", + }, + homepageSection: { + registration: "app.slots.homepageSection", + role: "a plugin component rendered on bb's home page", + }, + messageAction: { + registration: "app.slots.messageAction", + role: "a host-rendered action contributed to a message", + }, + messageDirective: { + registration: "app.slots.messageDirective", + role: "a plugin component rendering an assistant message directive", + }, + navPanel: { + registration: "app.slots.navPanel", + role: "the plugin-owned route panel", + }, + "navPanel.experimental_fixedTabs": { + registration: "app.slots.navPanel({ experimental_fixedTabs })", + role: "a fixed tab declared by the plugin's navigation panel", + }, + "navPanel.headerContent": { + registration: "app.slots.navPanel({ headerContent })", + role: "the plugin component rendered in its panel header", + }, + "navPanel.experimental_sidebarAccessory": { + registration: "app.slots.navPanel({ experimental_sidebarAccessory })", + role: "an accessory declared beside the plugin's sidebar entry", + }, + "experimental_newThreadPanelAction.component": { + registration: "app.slots.experimental_newThreadPanelAction({ component })", + role: "the plugin panel opened from the new-thread action launcher", + }, + "experimental_newThreadPanelAction.run": { + registration: "app.slots.experimental_newThreadPanelAction({ run })", + role: "the host-rendered new-thread action and its run handler", + }, + pendingInteraction: { + registration: "app.slots.pendingInteraction", + role: "a plugin component handling a pending thread interaction", + }, + experimental_providerIcon: { + registration: "app.slots.experimental_providerIcon", + role: "a plugin component drawing an agent provider icon", + }, + settingsSection: { + registration: "app.slots.settingsSection", + role: "a plugin component rendered in its settings page", + }, + sidebarFooterAction: { + registration: "app.slots.sidebarFooterAction", + role: "a host-rendered plugin action in the sidebar footer", + }, + experimental_threadHeaderAction: { + registration: "app.slots.experimental_threadHeaderAction", + role: "a plugin component rendered in the active thread header", + }, + experimental_threadList: { + registration: "app.slots.experimental_threadList", + role: "the plugin component replacing bb's sidebar thread list", + }, + "threadPanelAction.component": { + registration: "app.slots.threadPanelAction({ component })", + role: "the plugin panel opened from a thread action launcher", + }, + "threadPanelAction.run": { + registration: "app.slots.threadPanelAction({ run })", + role: "the host-rendered thread action and its run handler", + }, + inline: { + registration: "app.contentScripts.register or custom plugin DOM", + role: "trusted plugin content rendered outside a named component slot", + }, + overlay: { + registration: "app.contentScripts.register or a plugin portal", + role: "trusted plugin content rendered in an overlay", + }, +} as const satisfies Readonly>; + +/** Source-oriented context for a captured public surface, including future ones. */ +export function pluginUiSurfacePromptContext(surface: string): PluginUiSurfacePromptContext { + return ( + PROMPT_CONTEXT_BY_SURFACE[surface as keyof typeof PROMPT_CONTEXT_BY_SURFACE] ?? { + registration: surface, + role: "a plugin UI contribution registered from the plugin frontend", + } + ); +} diff --git a/plugins/agentation/lib/plugin-ui-surface.ts b/plugins/agentation/lib/plugin-ui-surface.ts index 8f23b19..8f1fd91 100644 --- a/plugins/agentation/lib/plugin-ui-surface.ts +++ b/plugins/agentation/lib/plugin-ui-surface.ts @@ -8,37 +8,16 @@ // agent should inspect instead of the old catch-all `inline` label. import { panelPluginIdFromRoute } from "./route.ts"; +import { PUBLIC_SURFACE_BY_SLOT_KIND } from "./plugin-ui-surface-map.ts"; + +export { PUBLIC_SURFACE_BY_SLOT_KIND } from "./plugin-ui-surface-map.ts"; export interface PluginUiSurfaceContext { pluginId: string | null; surface: string | null; + surfaceId: string | null; } -/** - * bb's renderer names its component boundaries without experimental prefixes - * or registration member names. Keep the translation beside the annotation - * code so the stored value matches the public SDK API shown to plugin authors. - */ -export const PUBLIC_SURFACE_BY_SLOT_KIND = { - composerAction: "composer.actions", - composerBanner: "composer.banners", - composerPlusMenuItem: "composer.plusMenu", - fileOpener: "fileOpener", - homepageSection: "homepageSection", - messageDirective: "messageDirective", - navPanel: "navPanel", - navPanelFixedTab: "navPanel.experimental_fixedTabs", - navPanelHeaderContent: "navPanel.headerContent", - navPanelSidebarAccessory: "navPanel.experimental_sidebarAccessory", - newThreadPanelAction: "experimental_newThreadPanelAction.component", - pendingInteraction: "pendingInteraction", - providerIcon: "experimental_providerIcon", - settingsSection: "settingsSection", - threadHeaderAction: "experimental_threadHeaderAction", - threadList: "experimental_threadList", - threadPanelAction: "threadPanelAction.component", -} as const satisfies Readonly>; - type ReactFiber = { memoizedProps?: unknown; return?: ReactFiber | null; @@ -95,7 +74,11 @@ function componentBoundaryFor(element: Element): PluginUiSurfaceContext | null { const pluginId = nonEmptyString(props?.pluginId); const slotKind = nonEmptyString(props?.slotKind); if (pluginId && slotKind) { - return { pluginId, surface: publicSurface(slotKind) }; + return { + pluginId, + surface: publicSurface(slotKind), + surfaceId: nonEmptyString(props?.slotId), + }; } fiber = fiber.return ?? null; depth += 1; @@ -169,6 +152,13 @@ function labelMatches(record: PluginRecord, labels: Set): boolean { ); } +function prefixedRegistrationId(record: PluginRecord, prefix: string): string | null { + const expectedPrefix = `${prefix}:${record.pluginId}:`; + return record.id?.startsWith(expectedPrefix) + ? nonEmptyString(record.id.slice(expectedPrefix.length)) + : null; +} + function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null { const labels = targetLabels(element); const records = pluginRecordsFromFiber(element); @@ -183,7 +173,13 @@ function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null record.id !== undefined && footerTestId === `plugin-sidebar-footer-action-${record.pluginId}-${record.id}`, ); - if (action) return { pluginId: action.pluginId, surface: "sidebarFooterAction" }; + if (action) { + return { + pluginId: action.pluginId, + surface: "sidebarFooterAction", + surfaceId: action.id ?? null, + }; + } } const panelAction = records.find( @@ -192,7 +188,11 @@ function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null labelMatches(record, labels), ); if (panelAction) { - return { pluginId: panelAction.pluginId, surface: "threadPanelAction.run" }; + return { + pluginId: panelAction.pluginId, + surface: "threadPanelAction.run", + surfaceId: prefixedRegistrationId(panelAction, "plugin-action"), + }; } const newThreadPanelAction = records.find( @@ -204,6 +204,7 @@ function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null return { pluginId: newThreadPanelAction.pluginId, surface: "experimental_newThreadPanelAction.run", + surfaceId: prefixedRegistrationId(newThreadPanelAction, "plugin-new-thread-action"), }; } @@ -218,7 +219,11 @@ function hostRenderedActionFor(element: Element): PluginUiSurfaceContext | null labelMatches(record, labels), ); if (messageAction) { - return { pluginId: messageAction.pluginId, surface: "messageAction" }; + return { + pluginId: messageAction.pluginId, + surface: "messageAction", + surfaceId: messageAction.key?.split("/")[1] ?? null, + }; } return null; @@ -243,7 +248,7 @@ function navPanelRowFor(element: Element): PluginUiSurfaceContext | null { element; const icon = row.querySelector("[data-plugin-icon-asset]"); const pluginId = pluginIdFromAssetUrl(icon?.getAttribute("data-plugin-icon-asset") ?? null); - return pluginId ? { pluginId, surface: "navPanel" } : null; + return pluginId ? { pluginId, surface: "navPanel", surfaceId: null } : null; } /** @@ -257,7 +262,7 @@ export function pluginUiSurfaceFor( element: Element | null, route: string, ): PluginUiSurfaceContext { - if (!element) return { pluginId: null, surface: null }; + if (!element) return { pluginId: null, surface: null, surfaceId: null }; const boundary = componentBoundaryFor(element); if (boundary) return boundary; @@ -265,14 +270,14 @@ export function pluginUiSurfaceFor( const richTextDecoration = element.closest("[data-bb-plugin-decoration]"); const richTextPluginId = richTextDecoration?.getAttribute("data-bb-plugin-decoration") ?? null; if (richTextPluginId) { - return { pluginId: richTextPluginId, surface: "composer.richText" }; + return { pluginId: richTextPluginId, surface: "composer.richText", surfaceId: null }; } const composerAction = element.closest("[data-plugin-composer-action-plugin]"); const composerActionPluginId = composerAction?.getAttribute("data-plugin-composer-action-plugin") ?? null; if (composerActionPluginId) { - return { pluginId: composerActionPluginId, surface: "composer.actions" }; + return { pluginId: composerActionPluginId, surface: "composer.actions", surfaceId: null }; } const hostAction = hostRenderedActionFor(element); @@ -283,16 +288,16 @@ export function pluginUiSurfaceFor( const owner = element.closest("[data-bb-plugin]"); const pluginId = owner?.getAttribute("data-bb-plugin") ?? null; - if (!pluginId) return { pluginId: null, surface: null }; + if (!pluginId) return { pluginId: null, surface: null, surfaceId: null }; if (element.closest("[data-testid='app-page-header-content-row']")) { - return { pluginId, surface: "navPanel.headerContent" }; + return { pluginId, surface: "navPanel.headerContent", surfaceId: null }; } if (element.closest("[data-bb-portaled-overlay]")) { - return { pluginId, surface: "overlay" }; + return { pluginId, surface: "overlay", surfaceId: null }; } if (panelPluginIdFromRoute(route) === pluginId) { - return { pluginId, surface: "navPanel" }; + return { pluginId, surface: "navPanel", surfaceId: null }; } - return { pluginId, surface: "inline" }; + return { pluginId, surface: "inline", surfaceId: null }; } diff --git a/plugins/agentation/lib/toolbar.ts b/plugins/agentation/lib/toolbar.ts index a7f85fb..c2d1b83 100644 --- a/plugins/agentation/lib/toolbar.ts +++ b/plugins/agentation/lib/toolbar.ts @@ -261,11 +261,12 @@ async function mountAnnotationToolbarRoot( let stream: EventSource | null = null; function contextForNewAnnotation(): BbContext { - const { pluginId, surface } = pluginUiSurfaceFor(lastTarget, meta.route); + const { pluginId, surface, surfaceId } = pluginUiSurfaceFor(lastTarget, meta.route); return { route: meta.route, pluginId, surface, + surfaceId, threadId: meta.threadId, projectId: meta.projectId, routeLabel: labelForRoute(meta.route), diff --git a/plugins/agentation/server.ts b/plugins/agentation/server.ts index c7388fd..bd04c4f 100644 --- a/plugins/agentation/server.ts +++ b/plugins/agentation/server.ts @@ -652,7 +652,7 @@ export default async function plugin(bb: BbPluginApi) { bb.agents.registerTool({ name: "agentation_get_pending", description: - "Get the open (pending or acknowledged) annotations for one session, rendered with the bb route, owning plugin, and DOM selector for each.", + "Get the open (pending or acknowledged) annotations for one session, rendered with the bb route, owning plugin, SDK UI registration, and DOM selector for each.", experimental_statusLabels: { pending: "Reading pending annotations", completed: "Read pending annotations", @@ -674,7 +674,7 @@ export default async function plugin(bb: BbPluginApi) { description: "Get every open annotation across all bb pages. Use this when the human refers to UI feedback but did not supply a self-contained Agentation annotation batch.", instructions: - "When the human refers to feedback they left on the bb interface and their message does not already contain an Agentation annotation batch, read it with agentation_get_all_pending before searching the code. A supplied batch is self-contained; do not fetch other pending feedback. Each annotation names the bb route and, for plugin surfaces, the owning plugin id.", + "When the human refers to feedback they left on the bb interface and their message does not already contain an Agentation annotation batch, read it with agentation_get_all_pending before searching the code. A supplied batch is self-contained; do not fetch other pending feedback. Each annotation names the bb route and, for plugin surfaces, the owning plugin id and public UI registration. Start at that registration in the plugin's app.tsx before narrowing with its selector and React path.", experimental_statusLabels: { pending: "Reading all pending annotations", completed: "Read all pending annotations", diff --git a/plugins/agentation/skills/agentation/SKILL.md b/plugins/agentation/skills/agentation/SKILL.md index 2dee98b..f32fd6d 100644 --- a/plugins/agentation/skills/agentation/SKILL.md +++ b/plugins/agentation/skills/agentation/SKILL.md @@ -1,14 +1,15 @@ --- name: agentation -description: Read and act on visual feedback the human left on the bb interface with the Agentation toolbar — annotations that name a bb route, the owning plugin, and a DOM selector. Use when the user says they annotated, marked up, or left feedback on the UI, when they ask you to address annotation N, or when they ask for watch mode, hands-free mode, or a UI critique loop. +description: Read and act on visual feedback the human left on the bb interface with the Agentation toolbar — annotations that name a bb route, the owning plugin, its SDK UI registration, and a DOM selector. Use when the user says they annotated, marked up, or left feedback on the UI, when they ask you to address annotation N, or when they ask for watch mode, hands-free mode, or a UI critique loop. --- # Agentation The human points at part of the bb interface and writes what should change. Each annotation carries the bb route, the owning plugin id when the element was drawn -by a plugin, the DOM selector, and — for React trees — the component path. Your -job is to turn that into a code change and close the loop. +by a plugin, the public SDK registration and item id when detectable, the DOM +selector, and — for React trees — the component path. Your job is to turn that +into a code change and close the loop. Annotations first enter a shared staging area. The human assigns a staged batch from the composer banner in the thread that should own it. The capture route is @@ -34,16 +35,19 @@ annotation you did not actually fix — dismiss it or ask. ## Locating the code -The `Where` line is the fastest route to the source. +The `Where` and `Plugin UI` lines are the fastest route to the source. -| `Where` says | The code lives in | -| ----------------- | ----------------------------------------- | -| `plugin \`\`` | that plugin's `app.tsx` and `components/` | -| `bb app shell` | the bb app itself, not this workspace | +| Location says | The code lives in | +| ----------------- | ------------------------------------------------------ | +| `plugin \`\`` | that plugin's `app.tsx` registration and `components/` | +| `bb app shell` | the bb app itself, not this workspace | -`Selector` is a live DOM path — grep it for class names and element structure. -`React` is the component path; the last segment is usually the component to -open. `Source` is a file path when the toolbar could recover one. +For plugin-owned UI, start with the exact registration named on `Plugin UI` in +the plugin's `app.tsx`. Its optional `registration` id narrows the match when a +plugin contributes several items to the same surface. Then use `Selector`, a +live DOM path, for class names and element structure. `React` is the component +path; the last segment is usually the component to open. `Source` is a file path +when the toolbar could recover one. An annotation on the bb app shell is only actionable inside a bb checkout. If the workspace is not one, say so and reply on the annotation rather than guessing. diff --git a/plugins/agentation/test/plugin-ui-surface.test.ts b/plugins/agentation/test/plugin-ui-surface.test.ts index cdae2c3..bc118e2 100644 --- a/plugins/agentation/test/plugin-ui-surface.test.ts +++ b/plugins/agentation/test/plugin-ui-surface.test.ts @@ -99,6 +99,7 @@ test("every bb plugin component boundary maps to its public SDK surface", () => assert.deepEqual(pluginUiSurfaceFor(asElement(target), "/"), { pluginId: "example-plugin", surface, + surfaceId: "example", }); } }); @@ -115,6 +116,7 @@ test("an unknown future component boundary remains useful without a code update" assert.deepEqual(pluginUiSurfaceFor(asElement(target), "/"), { pluginId: "future-plugin", surface: "futurePreview", + surfaceId: "preview", }); }); @@ -126,6 +128,7 @@ test("composer paint and host-rendered actions carry their owning plugin", () => assert.deepEqual(pluginUiSurfaceFor(asElement(richText), "/threads/thr_1"), { pluginId: "amp", surface: "composer.richText", + surfaceId: null, }); const composerAction = new FakeElement({ @@ -135,6 +138,7 @@ test("composer paint and host-rendered actions carry their owning plugin", () => assert.deepEqual(pluginUiSurfaceFor(asElement(composerAction), "/threads/thr_1"), { pluginId: "review", surface: "composer.actions", + surfaceId: null, }); }); @@ -155,7 +159,11 @@ test("host-rendered action descriptors distinguish launchers, messages, and the }, }, ), - expected: { pluginId: "gh-stack", surface: "threadPanelAction.run" }, + expected: { + pluginId: "gh-stack", + surface: "threadPanelAction.run", + surfaceId: "stack", + }, }, { element: withFiber( @@ -175,6 +183,7 @@ test("host-rendered action descriptors distinguish launchers, messages, and the expected: { pluginId: "planner", surface: "experimental_newThreadPanelAction.run", + surfaceId: "plan", }, }, { @@ -192,7 +201,11 @@ test("host-rendered action descriptors distinguish launchers, messages, and the }, }, ), - expected: { pluginId: "support", surface: "messageAction" }, + expected: { + pluginId: "support", + surface: "messageAction", + surfaceId: "send-to-inbox", + }, }, ] as const; @@ -219,6 +232,7 @@ test("host-rendered action descriptors distinguish launchers, messages, and the assert.deepEqual(pluginUiSurfaceFor(asElement(footer), "/"), { pluginId: "connect", surface: "sidebarFooterAction", + surfaceId: "remote-access", }); }); @@ -230,6 +244,7 @@ test("manual plugin roots retain precise header and route fallbacks", () => { assert.deepEqual(pluginUiSurfaceFor(asElement(header), "/plugins/notes/notes"), { pluginId: "notes", surface: "navPanel.headerContent", + surfaceId: null, }); const body = new FakeElement(); @@ -237,10 +252,12 @@ test("manual plugin roots retain precise header and route fallbacks", () => { assert.deepEqual(pluginUiSurfaceFor(asElement(body), "/plugins/notes/notes"), { pluginId: "notes", surface: "navPanel", + surfaceId: null, }); assert.deepEqual(pluginUiSurfaceFor(null, "/"), { pluginId: null, surface: null, + surfaceId: null, }); }); diff --git a/plugins/agentation/test/rendering.test.ts b/plugins/agentation/test/rendering.test.ts index f5a594b..08517db 100644 --- a/plugins/agentation/test/rendering.test.ts +++ b/plugins/agentation/test/rendering.test.ts @@ -32,6 +32,7 @@ function stored(overrides: Partial = {}): StoredAnnotation { route: "/plugins/github/issues", pluginId: "github", surface: "navPanel", + surfaceId: "issues", threadId: null, projectId: null, routeLabel: "github panel", @@ -46,11 +47,39 @@ function stored(overrides: Partial = {}): StoredAnnotation { test("an annotation on a plugin surface names the owning plugin", () => { const output = renderAnnotation(stored()); - assert.match(output, /plugin `github` \(navPanel\)/); + assert.match(output, /plugin `github` · route `\/plugins\/github\/issues` \(github panel\)/); + assert.match(output, /\*\*Plugin UI:\*\* `app\.slots\.navPanel` · registration `issues`/); + assert.match(output, /the plugin-owned route panel/); + assert.match(output, /plugin `github`'s `app\.tsx`/); assert.match(output, /body > main > button\.cta/); assert.match(output, /The label wraps at 320px/); }); +test("a thread-list annotation points an agent to the exact GTD registration", () => { + const output = renderAnnotation( + stored({ + bb: { + route: "/", + pluginId: "gtd-sidebar", + surface: "experimental_threadList", + surfaceId: "inbox", + threadId: null, + projectId: null, + routeLabel: "home", + }, + reactComponents: " ", + }), + ); + + assert.match(output, /\*\*Where:\*\* plugin `gtd-sidebar` · route `\/` \(home\)/); + assert.match( + output, + /\*\*Plugin UI:\*\* `app\.slots\.experimental_threadList` · registration `inbox`/, + ); + assert.match(output, /replacing bb's sidebar thread list/); + assert.match(output, /Start at this registration in plugin `gtd-sidebar`'s `app\.tsx`/); +}); + test("an annotation on the shell says so instead of naming a plugin", () => { const output = renderAnnotation( stored({ @@ -58,6 +87,7 @@ test("an annotation on the shell says so instead of naming a plugin", () => { route: "/", pluginId: null, surface: null, + surfaceId: null, threadId: null, projectId: null, routeLabel: "home", diff --git a/plugins/agentation/test/staging.test.ts b/plugins/agentation/test/staging.test.ts index 5d12f94..22e2012 100644 --- a/plugins/agentation/test/staging.test.ts +++ b/plugins/agentation/test/staging.test.ts @@ -41,6 +41,7 @@ function seed(db: Database.Database, id = "ann_1") { route: session.route, pluginId: null, surface: null, + surfaceId: null, threadId: session.threadId, projectId: session.projectId, routeLabel: "thread thr_source", diff --git a/plugins/agentation/test/store.test.ts b/plugins/agentation/test/store.test.ts index 0c1ee98..b7c9920 100644 --- a/plugins/agentation/test/store.test.ts +++ b/plugins/agentation/test/store.test.ts @@ -30,6 +30,7 @@ function bbContext(overrides: Partial = {}): BbContext { route: "/threads/thr_abc", pluginId: null, surface: null, + surfaceId: null, threadId: "thr_abc", projectId: null, routeLabel: "thread thr_abc", @@ -124,6 +125,7 @@ test("an annotation keeps the surface it was captured on across edits", () => { route: "/plugins/github/issues", pluginId: "github", surface: "navPanel", + surfaceId: "issues", threadId: null, }), }); @@ -132,12 +134,13 @@ test("an annotation keeps the surface it was captured on across edits", () => { const edited = upsertAnnotation(db, { sessionId: session.id, annotation: annotation({ comment: "Now says the wrong count" }), - bb: bbContext({ pluginId: null, surface: null }), + bb: bbContext({ pluginId: null, surface: null, surfaceId: null }), }); assert.equal(edited.comment, "Now says the wrong count"); assert.equal(edited.bb.pluginId, "github"); assert.equal(edited.bb.surface, "navPanel"); + assert.equal(edited.bb.surfaceId, "issues"); }); test("an edit never rewinds a status the agent already advanced", () => {