diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index 332494c36f..62fc46b420 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -11,7 +11,7 @@ import { } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import { PERSONAL_PROJECT_ID, type PromptInput } from "@bb/domain"; import type { PluginComposerApi, PluginFileOpenerProps, @@ -222,6 +222,45 @@ function NewThreadDraftSeeder() { ); } +const EXPECTED_COMPOSER_INPUT = [ + { + type: "text", + text: "Describe this screenshot", + mentions: [], + }, + { type: "localImage", path: "uploads/screenshot.png" }, +] satisfies PromptInput[]; + +function ThreadInputSnapshotSeeder({ threadId }: { threadId: string }) { + const draft = usePromptDraftStorage({ + kind: "thread", + projectId: PERSONAL_PROJECT_ID, + threadId, + }); + return ( + + ); +} + describe("useComposer", () => { beforeEach(() => { window.localStorage.clear(); @@ -235,12 +274,15 @@ describe("useComposer", () => { const composer = useComposer(); onRender?.(composer); const initialMethods = useRef({ + experimental_getInput: composer.experimental_getInput, setText: composer.setText, updateText: composer.updateText, clear: composer.clear, setTextEffect: composer.setTextEffect, }); const methodsAreStable = + initialMethods.current.experimental_getInput === + composer.experimental_getInput && initialMethods.current.setText === composer.setText && initialMethods.current.updateText === composer.updateText && initialMethods.current.clear === composer.clear && @@ -491,6 +533,39 @@ describe("useComposer", () => { ).toHaveLength(1); }); + it("snapshots the exact text and attachments the composer would submit", () => { + let composerApi: PluginComposerApi | null = null; + registerComposerProbe("snapshot", (composer) => { + composerApi = composer; + }); + render( + + + + , + ); + + fireEvent.click(screen.getByText("seed-input-snapshot")); + + const currentComposer = composerApi as PluginComposerApi | null; + if (currentComposer === null) throw new Error("composer did not render"); + expect(currentComposer.experimental_getInput()).toEqual( + EXPECTED_COMPOSER_INPUT, + ); + const detached = currentComposer.experimental_getInput(); + const detachedText = detached[0]; + const detachedImage = detached[1]; + if (detachedText?.type !== "text" || detachedImage?.type !== "localImage") { + throw new Error("unexpected composer input fixture"); + } + detachedText.text = "mutated snapshot"; + detachedImage.path = "uploads/mutated.png"; + + expect(currentComposer.experimental_getInput()).toEqual( + EXPECTED_COMPOSER_INPUT, + ); + }); + it("binds composer writes to the active queued-message editor", () => { registerComposerProbe("queued"); diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 3f483f42e7..f79546895b 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -8,7 +8,7 @@ import { } from "react"; import { useQuery } from "@tanstack/react-query"; import { matchPath, useLocation, useNavigate } from "react-router-dom"; -import type { PromptTextMention } from "@bb/domain"; +import type { PromptInput, PromptTextMention } from "@bb/domain"; import type { BbContext, BbNavigate, @@ -39,6 +39,7 @@ import { import { appendQuoteAndAttachmentsToDraft, isPromptDraftEmpty, + promptDraftToInput, } from "@/lib/prompt-draft"; import { AUTOMATIONS_PLUGIN_ID, @@ -72,6 +73,20 @@ type FetchLike = ( * those bundles are outside the supported upgrade window. */ const legacySetThreadRowStatus = (_status: unknown): void => {}; + +function clonePromptInput(input: readonly PromptInput[]): PromptInput[] { + return input.map((chunk) => + chunk.type === "text" + ? { + ...chunk, + mentions: chunk.mentions.map((mention) => ({ + ...mention, + resource: { ...mention.resource }, + })), + } + : { ...chunk }, + ); +} export function isAutomationEditRoutePath(pathname: string): boolean { return ( matchPath({ path: AUTOMATION_EDIT_ROUTE_PATH, end: true }, pathname) !== @@ -589,6 +604,11 @@ export function useComposer(): PluginComposerApi { setText(""); }, [setText]); + const experimentalGetInput = useCallback( + () => clonePromptInput(promptDraftToInput(getCurrent())), + [getCurrent], + ); + const composerScope = composerHost?.scope; const composerOwnershipScopeKey = composerScope?.kind === "queued-message" @@ -739,6 +759,7 @@ export function useComposer(): PluginComposerApi { ? { kind: "thread", threadId } : { kind: "new-thread", projectId: projectId ?? null }), text: composerText, + experimental_getInput: experimentalGetInput, setText, updateText, clear, @@ -754,6 +775,7 @@ export function useComposer(): PluginComposerApi { clear, composerScope, composerText, + experimentalGetInput, focus, insertMention, projectId, diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 97f2619003..68f18f3158 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1881,7 +1881,14 @@ openThreadPanel({ actionId, title?, params? }) }`. returns false on surfaces without a thread side panel. - `useComposer()` → programmatic access to the chat composer draft (the same one the built-in "Add to chat" affordances write to): - `text` is the current plain text; `setText(next)` replaces it; + `text` is the current plain text; + `experimental_getInput()` returns a detached snapshot of the exact + structured input the composer would submit, including mention ranges and + attached images/files, without mutating the draft; relative attachment + paths are project-scoped, so pass the snapshot unchanged only to a thread in + the same project, or call + `bb.sdk.projects.attachments.copy({ sourceProjectId, projectId, paths })` + before spawning in a different project; `setText(next)` replaces it; `updateText(current => next)` receives the latest committed text; and `clear()` clears the text. These edits preserve attachments. Inline mentions outside the changed range are preserved and rebased, while a diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 694fc734aa..28a75f520c 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -359,6 +359,36 @@ fetches and four icon remounts at every boot. the accessible label story: the host derives `ariaLabel` from its own provider data, falling back to the provider id, and the slot supplies none. +## `PluginComposerApi.experimental_getInput` (`@get-bb/plugin-sdk/app`) + +**What it does.** Returns a detached `PromptInput[]` snapshot of the exact +draft the active composer would submit at call time. The snapshot includes +trimmed text, structured mention ranges, and independently attached local +images/files. It is bound to the same thread, queued-message, side-chat, or +new-thread composer scope as the rest of `useComposer()` and does not mutate, +clear, focus, or submit that composer. A plugin can forward the snapshot to its +server and then to `bb.sdk.threads.spawn({ input })` without flattening image +context into text. +Relative attachment paths are project-scoped, so forwarding to a different +project requires copying those paths with `projects.attachments.copy` first. + +**Audit before stabilizing.** + +1. Confirm real consumers need the complete `PromptInput[]`, rather than a + narrower attachment-reader or one host-owned "spawn from composer" action. +2. Confirm local attachment paths remain the correct portable reference when + the receiving thread uses the same project, and document or enforce the + cross-project boundary if a consumer needs one. +3. Audit snapshot timing against concurrent attachment uploads and composer + submission. The call is synchronous and reports the last committed draft; + an upload that has not entered the draft is intentionally absent. +4. Confirm returning a detached mutable array is preferable to a readonly + contract. Mutating the snapshot cannot mutate the composer, but readonly + types could make that intent clearer before stabilization. +5. Exercise text, plugin/command mentions, local images, local files, queued + messages, side chats, root compose, and split-pane thread composers before + removing the prefix. + ## `experimental_NewThreadComposer` (`@get-bb/plugin-sdk/app`) **What it does.** The host-owned new-thread compose surface, the create-side diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 35236de83a..86b8d804df 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -16,6 +16,13 @@ Composer UI extensions register through `app.composer.customize(...)`. A host-rendered `ComposerPlusMenuItem` rows, and `ComposerRichTextSpec` rules. Mounted components use `useComposer()` for writes, effects, and input locking, and `useComposerView()` for the reactive scope, layout, draft, and run state. +When a plugin needs to hand the current draft to another BB thread without +losing screenshots, files, or structured mentions, call +`useComposer().experimental_getInput()` at the user action boundary and pass +that detached input through unchanged when the destination thread is in the +same project. Relative attachment paths are project-scoped; before spawning in +a different project, copy those paths with +`bb.sdk.projects.attachments.copy({ sourceProjectId, projectId, paths })`. Any mounted plugin component can use `useBbNavigate().openThreadPanel(...)` to request one of the same plugin's registered thread-panel actions; it returns false when the diff --git a/packages/plugin-sdk/src/__tests__/bundled-types.test.ts b/packages/plugin-sdk/src/__tests__/bundled-types.test.ts index e19d74af7e..ff0c813eb5 100644 --- a/packages/plugin-sdk/src/__tests__/bundled-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/bundled-types.test.ts @@ -37,6 +37,12 @@ describe("bundled plugin SDK declarations", () => { ); expect(appDeclarations).not.toContain("PluginCatalogArea"); expect(appDeclarations).not.toContain("applyUpdate(args: PluginIdArgs)"); + expect(appDeclarations).toContain( + "experimental_getInput(): PromptInput[];", + ); + expect(appDeclarations).not.toMatch( + /export type \{[^}]*\bPromptInput\b[^}]*\};/u, + ); expect(declarations).toContain( "list(args?: ProviderListArgs): Promise;", ); diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 9b82efe875..4e6fbfc83d 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1123,6 +1123,15 @@ export interface PluginComposerApi { scope: PluginComposerScope; /** Current plain text for this composer scope. */ readonly text: string; + /** + * Snapshot the exact structured input this composer would submit now, + * including text mentions and independently attached images/files. The + * returned array is detached from the live draft; reading or changing it + * never mutates the composer. + * + * Experimental: see docs/api_to_audit.md. + */ + experimental_getInput(): PromptInput[]; /** * Replace the draft's plain text. Attachments are preserved. Inline mentions * outside the changed range are preserved and rebased; mentions overlapped diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 04eafb2d1b..77baae4515 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { cleanup, fireEvent, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import type { PromptInput } from "@bb/domain"; import type { PluginComposerApi, PluginComposerScope, @@ -88,6 +89,28 @@ let capturedComposerVisualSetters: Pick< PluginComposerApi, "setTextEffect" | "setInputLock" > | null = null; +let capturedComposerGetInput: + | PluginComposerApi["experimental_getInput"] + | null = null; + +function makePluginMention( + label: string, + start: number, + end: number, + pluginId = "source-plugin", +) { + return { + start, + end, + resource: { + kind: "plugin", + pluginId, + icon: null, + itemId: `notes:${label.toLowerCase()}`, + label, + }, + } as const; +} function InlineVis({ attributes, @@ -106,6 +129,7 @@ function InlineVis({ function ComposerProbe() { const composer = useComposer(); const view = useComposerView(); + capturedComposerGetInput = composer.experimental_getInput; capturedComposerVisualSetters = { setTextEffect: composer.setTextEffect, setInputLock: composer.setInputLock, @@ -1109,6 +1133,74 @@ describe("renderSlot", () => { expect(slot.composer.scope).toEqual(nextScope); }); + it("exposes structured composer text and screenshot attachments", () => { + const input = [ + { type: "text", text: "Inspect the screenshot", mentions: [] }, + { type: "localImage", path: "uploads/screenshot.png" }, + ] satisfies PromptInput[]; + renderSlot( + app.composerCustomizations[0]!.actions![0]!, + {}, + { composer: { input } }, + ); + + if (capturedComposerGetInput === null) { + throw new Error("composer did not render"); + } + expect(capturedComposerGetInput()).toEqual(input); + + const originalText = input[0]; + if (originalText?.type !== "text") { + throw new Error("unexpected composer input fixture"); + } + originalText.text = "mutated fixture"; + expect(capturedComposerGetInput()).toEqual([ + { type: "text", text: "Inspect the screenshot", mentions: [] }, + { type: "localImage", path: "uploads/screenshot.png" }, + ]); + }); + + it("keeps structured mentions aligned with composer edits and inserts", async () => { + const input = [ + { + type: "text", + text: "Alpha Beta Gamma", + mentions: [ + makePluginMention("Alpha", 0, 5), + makePluginMention("Beta", 6, 10), + makePluginMention("Gamma", 11, 16), + ], + }, + { type: "localImage", path: "uploads/screenshot.png" }, + ] satisfies PromptInput[]; + const slot = renderSlot( + app.composerCustomizations[0]!.actions![0]!, + {}, + { composer: { input } }, + ); + + if (capturedComposerGetInput === null) { + throw new Error("composer did not render"); + } + expect(capturedComposerGetInput()).toEqual(input); + + await slot.behavior.setComposerText("Alpha BETA!! Gamma"); + fireEvent.click(slot.getByText("mention")); + + expect(capturedComposerGetInput()).toEqual([ + { + type: "text", + text: "Alpha BETA!! Gamma Ideas ", + mentions: [ + makePluginMention("Alpha", 0, 5), + makePluginMention("Gamma", 13, 18), + makePluginMention("Ideas", 19, 24, "test-plugin"), + ], + }, + { type: "localImage", path: "uploads/screenshot.png" }, + ]); + }); + it.each([ { name: "attachment-only", diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 1759b4cd07..5b25393c31 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -11,6 +11,7 @@ import { type ReactNode, } from "react"; import { act, render, type RenderResult } from "@testing-library/react"; +import type { PromptInput } from "@bb/domain"; import { type BbContext, type BbNavigate, @@ -136,6 +137,68 @@ interface TestComposerStore { subscribe(listener: () => void): () => void; } +function clonePromptInput(input: readonly PromptInput[]): PromptInput[] { + return input.map((chunk) => + chunk.type === "text" + ? { + ...chunk, + mentions: chunk.mentions.map((mention) => ({ + ...mention, + resource: { ...mention.resource }, + })), + } + : { ...chunk }, + ); +} + +type PromptTextMention = Extract< + PromptInput, + { type: "text" } +>["mentions"][number]; + +function reconcileComposerMentions( + currentText: string, + nextText: string, + mentions: readonly PromptTextMention[], +): PromptTextMention[] { + if (currentText === nextText) return [...mentions]; + + let unchangedPrefixLength = 0; + const maximumPrefixLength = Math.min(currentText.length, nextText.length); + while ( + unchangedPrefixLength < maximumPrefixLength && + currentText[unchangedPrefixLength] === nextText[unchangedPrefixLength] + ) { + unchangedPrefixLength += 1; + } + + let unchangedSuffixLength = 0; + while ( + unchangedSuffixLength < currentText.length - unchangedPrefixLength && + unchangedSuffixLength < nextText.length - unchangedPrefixLength && + currentText[currentText.length - unchangedSuffixLength - 1] === + nextText[nextText.length - unchangedSuffixLength - 1] + ) { + unchangedSuffixLength += 1; + } + + const replacedCurrentEnd = currentText.length - unchangedSuffixLength; + const replacementDelta = nextText.length - currentText.length; + return mentions.flatMap((mention) => { + if (mention.end <= unchangedPrefixLength) return [mention]; + if (mention.start >= replacedCurrentEnd) { + return [ + { + ...mention, + start: mention.start + replacementDelta, + end: mention.end + replacementDelta, + }, + ]; + } + return []; + }); +} + interface SlotEnv { rpcClient: PluginRpcClient; rpcCalls: RpcCall[]; @@ -716,11 +779,21 @@ export interface RenderSlotOptions< /** Initial `useRealtimeConnectionState()` value; defaults to `connected`. */ realtimeConnectionState?: PluginRealtimeConnectionState; /** Initial state for this render's isolated composer scope and view. */ - composer?: { - text?: string; - scope?: PluginComposerScope; - attachmentCount?: number; - }; + composer?: + | { + /** Exact structured composer input, including image/file attachments. */ + input: PromptInput[]; + scope?: PluginComposerScope; + text?: never; + attachmentCount?: never; + } + | { + /** Legacy shorthand for tests that only need text/count view state. */ + input?: never; + text?: string; + scope?: PluginComposerScope; + attachmentCount?: number; + }; /** * Threads and projects `experimental_useSidebarThreads()` reports. Omitted → * a ready, empty list. Pass `{ status: "loading" }` to test that branch. @@ -951,23 +1024,49 @@ export function renderSlot< const projectId = options.context?.projectId ?? null; const threadId = options.context?.threadId ?? null; + const pluginId = "test-plugin"; let composerScope: PluginComposerScope = options.composer?.scope ?? (threadId !== null ? { kind: "thread", threadId } : { kind: "new-thread", projectId }); - let composerText = options.composer?.text ?? ""; - const composerAttachmentCount = options.composer?.attachmentCount ?? 0; + const initialComposerInput = options.composer?.input; + let composerInput: PromptInput[] = + initialComposerInput !== undefined + ? clonePromptInput(initialComposerInput) + : options.composer?.text + ? [{ type: "text", text: options.composer.text, mentions: [] }] + : []; + let composerText = + composerInput.find((chunk) => chunk.type === "text")?.text ?? ""; + const composerAttachmentCount = + initialComposerInput !== undefined + ? composerInput.filter((chunk) => chunk.type !== "text").length + : (options.composer?.attachmentCount ?? 0); let composerVersion = 0; const composerListeners = new Set<() => void>(); const notifyComposerListeners = () => { composerVersion += 1; for (const listener of composerListeners) listener(); }; - const commitComposerText = (next: string) => { + const commitComposerText = ( + next: string, + appendedMentions: readonly PromptTextMention[] = [], + ) => { if (next === composerText) return; + const currentMentions = + composerInput.find((chunk) => chunk.type === "text")?.mentions ?? []; + const mentions = [ + ...reconcileComposerMentions(composerText, next, currentMentions), + ...appendedMentions, + ]; composerText = next; + const attachments = composerInput.filter((chunk) => chunk.type !== "text"); + composerInput = + next.length === 0 + ? attachments + : [{ type: "text", text: next, mentions }, ...attachments]; notifyComposerListeners(); }; const composerLog: ComposerLog = { @@ -999,6 +1098,9 @@ export function renderSlot< return () => composerListeners.delete(listener); }, api: { + experimental_getInput() { + return clonePromptInput(composerInput); + }, setText(next) { commitComposerText(next); }, @@ -1033,10 +1135,31 @@ export function renderSlot< composerLog.focusCount += 1; }, insertMention(mention) { + const provider = mention.provider.trim(); const label = mention.label.trim() || mention.id; + if (provider.length === 0 || provider.includes(":")) { + console.warn( + `[plugin:${pluginId}] useComposer().insertMention: invalid provider id "${mention.provider}"`, + ); + return; + } const separator = composerText.length === 0 || /\s$/u.test(composerText) ? "" : " "; - commitComposerText(`${composerText}${separator}${label} `); + const start = composerText.length + separator.length; + const end = start + label.length; + commitComposerText(`${composerText}${separator}${label} `, [ + { + start, + end, + resource: { + kind: "plugin", + pluginId, + icon: null, + itemId: `${provider}:${mention.id}`, + label, + }, + }, + ]); composerLog.mentions.push(mention); composerLog.focusCount += 1; },