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;
},