Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<button
type="button"
onClick={() =>
draft.setDraft({
text: " Describe this screenshot ",
mentions: [],
attachments: [
{
type: "localImage",
path: "uploads/screenshot.png",
name: "screenshot.png",
sizeBytes: 2_048,
mimeType: "image/png",
},
],
})
}
>
seed-input-snapshot
</button>
);
}

describe("useComposer", () => {
beforeEach(() => {
window.localStorage.clear();
Expand All @@ -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 &&
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/threads/thr_snapshot"]}>
<ComposerCustomizationMount />
<ThreadInputSnapshotSeeder threadId="thr_snapshot" />
</MemoryRouter>,
);

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");

Expand Down
24 changes: 23 additions & 1 deletion apps/app/src/lib/plugin-sdk-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,6 +39,7 @@ import {
import {
appendQuoteAndAttachmentsToDraft,
isPromptDraftEmpty,
promptDraftToInput,
} from "@/lib/prompt-draft";
import {
AUTOMATIONS_PLUGIN_ID,
Expand Down Expand Up @@ -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) !==
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -754,6 +775,7 @@ export function useComposer(): PluginComposerApi {
clear,
composerScope,
composerText,
experimentalGetInput,
focus,
insertMention,
projectId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/api_to_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
brsbl marked this conversation as resolved.
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
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-sdk/src/__tests__/bundled-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderListResult>;",
);
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-sdk/src/app-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading