Skip to content
Merged
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
81 changes: 57 additions & 24 deletions apps/app/src/components/plugin/PluginPanelActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from "react";
import type { JsonValue } from "@get-bb/plugin-sdk";
import type { PluginPanelActionOpenOptions } from "@get-bb/plugin-sdk";
import { EmptyStatePanel } from "@bb/shared-ui/empty-state";
import {
usePluginSlots,
Expand Down Expand Up @@ -48,31 +48,70 @@ export interface PluginPanelActionEntry {
onSelect: () => void;
}

interface RunPluginPanelActionArgs {
action: PluginThreadPanelActionSlot;
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface PanelActionOpenPanelArgs {
action: { pluginId: string; id: string; title: string };
/** Slot name as it appears in log lines. */
slot: string;
openPluginPanel: OpenPluginPanelHandler;
threadId: string;
}

function runPluginPanelAction({
/**
* The `openPanel` handed to a panel action's `run`. A declined open — here
* only non-JSON `params`, since the launcher lives in the panel the action
* opens into — is logged and reported as `false` rather than thrown: `run`
* errors are contained below, so a throw would be invisible to any plugin
* that did not wrap the call itself.
*/
function createPanelActionOpenPanel({
action,
slot,
openPluginPanel,
threadId,
}: RunPluginPanelActionArgs): void {
const openPanel = (options?: { title?: string; params?: JsonValue }) => {
const paramsJson = serializePluginPanelParams(options?.params);
}: PanelActionOpenPanelArgs): (
options?: PluginPanelActionOpenOptions,
) => boolean {
return (options) => {
let paramsJson: string | null;
try {
paramsJson = serializePluginPanelParams(options?.params);
} catch (error) {
console.warn(
`[plugin:${action.pluginId}] ${slot} "${action.id}" openPanel declined: ${describeError(error)}`,
);
return false;
}
openPluginPanel({
pluginId: action.pluginId,
actionId: action.id,
title: options?.title ?? action.title,
paramsJson,
});
return true;
};
}

interface RunPluginPanelActionArgs {
action: PluginThreadPanelActionSlot;
openPluginPanel: OpenPluginPanelHandler;
threadId: string;
}

function runPluginPanelAction({
action,
openPluginPanel,
threadId,
}: RunPluginPanelActionArgs): void {
const openPanel = createPanelActionOpenPanel({
action,
slot: "threadPanelAction",
openPluginPanel,
});
const warn = (error: unknown) => {
console.warn(
`[plugin:${action.pluginId}] threadPanelAction "${action.id}" failed: ${
error instanceof Error ? error.message : String(error)
}`,
`[plugin:${action.pluginId}] threadPanelAction "${action.id}" failed: ${describeError(error)}`,
);
};
try {
Expand All @@ -98,20 +137,14 @@ function runPluginNewThreadPanelAction({
openPluginPanel,
projectId,
}: RunPluginNewThreadPanelActionArgs): void {
const openPanel = (options?: { title?: string; params?: JsonValue }) => {
const paramsJson = serializePluginPanelParams(options?.params);
openPluginPanel({
pluginId: action.pluginId,
actionId: action.id,
title: options?.title ?? action.title,
paramsJson,
});
};
const openPanel = createPanelActionOpenPanel({
action,
slot: "experimental_newThreadPanelAction",
openPluginPanel,
});
const warn = (error: unknown) => {
console.warn(
`[plugin:${action.pluginId}] experimental_newThreadPanelAction "${action.id}" failed: ${
error instanceof Error ? error.message : String(error)
}`,
`[plugin:${action.pluginId}] experimental_newThreadPanelAction "${action.id}" failed: ${describeError(error)}`,
);
};
try {
Expand Down
82 changes: 76 additions & 6 deletions apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1597,8 +1597,12 @@ describe("plugin thread panel actions", () => {
).toBeDefined();
});

it("contains a throwing run and rejects non-JSON params without opening", () => {
it("contains a throwing run and declines non-JSON params without opening", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// What each declined openPanel reported back to the plugin: a bad
// `params` must surface as false, never as a throw the plugin has to
// catch (the host swallows run errors, so a throw would be invisible).
const declines: boolean[] = [];
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
setPluginSlotRegistrations(
Expand All @@ -1617,14 +1621,19 @@ describe("plugin thread panel actions", () => {
id: "cyclic",
title: "Cyclic",
component: PanelProbe,
run: ({ openPanel }) => openPanel({ params: cyclic as never }),
run: ({ openPanel }) => {
declines.push(openPanel({ params: cyclic as never }));
},
},
{
id: "coerced",
title: "Coerced",
component: PanelProbe,
run: ({ openPanel }) =>
openPanel({ params: new Date("2026-01-01") as never }),
run: ({ openPanel }) => {
declines.push(
openPanel({ params: new Date("2026-01-01") as never }),
);
},
},
],
}),
Expand All @@ -1635,9 +1644,69 @@ describe("plugin thread panel actions", () => {
fireEvent.click(screen.getByText("Cyclic"));
fireEvent.click(screen.getByText("Coerced"));
expect(openPluginPanel).not.toHaveBeenCalled();
expect(declines).toEqual([false, false]);
expect(warn).toHaveBeenCalledTimes(3);
});

it("reports an accepted open as true from both panel action kinds", () => {
// The contract every openPanel entry point shares: an accepted open is
// true. Both action kinds are exercised in one test because the value of
// the boolean is that a plugin registering more than one kind can branch
// on it uniformly.
const accepted: boolean[] = [];
setPluginSlotRegistrations(
"demo",
registrationSet({
threadPanelActions: [
{
id: "issue",
title: "Thread action",
component: PanelProbe,
run: ({ openPanel }) => {
accepted.push(openPanel({ params: { source: "thread" } }));
},
},
],
newThreadPanelActions: [
{
id: "setup",
title: "Root action",
component: NewThreadPanelProbe,
run: ({ openPanel }) => {
accepted.push(openPanel({ params: { source: "root" } }));
},
},
],
}),
);

function BothActionsHarness() {
const threadEntries = usePluginPanelActions({
openPluginPanel: () => undefined,
threadId: "thr_9",
});
const rootEntries = usePluginNewThreadPanelActions({
openPluginPanel: () => undefined,
projectId: "proj_1",
});
return (
<div>
{[...threadEntries, ...rootEntries].map((entry) => (
<button key={entry.id} type="button" onClick={entry.onSelect}>
{entry.title}
</button>
))}
</div>
);
}

render(<BothActionsHarness />);
fireEvent.click(screen.getByText("Thread action"));
fireEvent.click(screen.getByText("Root action"));

expect(accepted).toEqual([true, true]);
});

it("offers no actions outside a thread context", () => {
setPluginSlotRegistrations(
"demo",
Expand All @@ -1664,11 +1733,12 @@ describe("plugin thread panel actions", () => {
title: "Set up thread",
icon: "Wand",
component: NewThreadPanelProbe,
run: ({ projectId, openPanel }) =>
run: ({ projectId, openPanel }) => {
openPanel({
title: `Setup for ${String(projectId)}`,
params: { source: "root" },
}),
});
},
},
],
}),
Expand Down
10 changes: 9 additions & 1 deletion apps/app/src/lib/plugin-message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ export function runPluginMessageAction({
message,
...(selectedText !== undefined ? { selectedText } : {}),
openPanel: (options) => {
if (openThreadPanel === undefined) return false;
if (openThreadPanel === undefined) {
// Reachable: only the main thread view supplies an opener, so a
// ThreadChat a plugin embeds in its own panel declines here. Logged
// so the false is diagnosable from the console.
console.warn(
`[plugin:${slot.pluginId}] messageAction "${slot.id}" openPanel declined: this surface has no thread side panel`,
);
return false;
}
return openThreadPanel({ ...options, pluginId: slot.pluginId });
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1267,15 +1267,17 @@ export default definePluginApp((app) => {
id: "issue",
title: "Open issue",
component: IssuePanel,
run: async ({ threadId, openPanel }) =>
openPanel({ title: `Issue for ${threadId}` }),
run: async ({ threadId, openPanel }) => {
openPanel({ title: `Issue for ${threadId}` });
},
});
app.slots.experimental_newThreadPanelAction({
id: "template",
title: "Apply template",
component: TemplatePanel,
run: ({ projectId, openPanel }) =>
openPanel({ title: `Template for ${projectId ?? "projectless"}` }),
run: ({ projectId, openPanel }) => {
openPanel({ title: `Template for ${projectId ?? "projectless"}` });
},
});
app.composer.customize({
id: "prompt-tools",
Expand Down Expand Up @@ -1588,6 +1590,13 @@ Slot props contracts (versioned, additive-only):
`run({ threadId, openPanel })` — do anything there (rpc, toast), and/or
call `openPanel({ title?, params? })` to open a closable panel tab
rendering `component` with `{ threadId: string, params: JsonValue | null }`.
`openPanel` returns `boolean` — true when the host accepted the open, false
when it declined (non-JSON `params`, unavailable action, or a surface with
no side panel). A decline is a return value, never a throw, and matches
`messageAction`'s `openPanel` and `useBbNavigate().openThreadPanel`, so one
open routine can serve every action kind. Because `run` is declared
`void | Promise<void>`, call `openPanel` from a braced body
(`run: ({ openPanel }) => { openPanel(); }`), not a concise arrow.
Omitting `run` opens a tab immediately with defaults. Write parameters are
typed as the recursively JSON-safe `JsonValue` exported by both
`@get-bb/plugin-sdk` and `@get-bb/plugin-sdk/app`; they persist with the tab across reloads (null when
Expand All @@ -1607,7 +1616,8 @@ Slot props contracts (versioned, additive-only):
calls `run({ projectId, openPanel })` and its component receives
`{ projectId: string | null, params: JsonValue | null }`; `projectId` is
null in projectless compose. Panel opening, JSON params, layout, persistence,
deduplication, and error containment otherwise match `threadPanelAction`.
deduplication, the `boolean` return, and error containment otherwise match
`threadPanelAction`.
Experimental: see `docs/api_to_audit.md`.
- Removed pre-1.0: `composerAccessory` was the legacy composer footer. Migrate
controls to `app.composer.customize({ actions })` or `plusMenu`, larger
Expand Down
7 changes: 6 additions & 1 deletion docs/api_to_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,12 @@ Before stabilization, audit:
plugin is removed, and in projectless compose has the right fallback.
5. **Relationship to `threadPanelAction`.** Confirm separate opt-in remains
preferable to a unified discriminated context after external plugins have
had time to adopt the root surface deliberately.
had time to adopt the root surface deliberately. The two contexts' `openPanel`
signatures were already unified: both take `PluginPanelActionOpenOptions` and
return `boolean` (true = accepted, false = declined), matching
`messageAction`'s `openPanel` and `useBbNavigate().openThreadPanel`. Do not
re-litigate that in the stabilization audit; audit only whether the two
_contexts_ should merge.

## `app.slots.experimental_threadList` (`@get-bb/plugin-sdk/app`)

Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ Any mounted plugin component can use
same plugin's registered thread-panel actions; it returns false when the
current surface has no thread side panel.

Every panel-open entry point reports the same way: `openThreadPanel` and the
`openPanel` handed to `threadPanelAction`, `experimental_newThreadPanelAction`,
and `messageAction` `run` callbacks all return `boolean` — true when the host
accepted the open, false when it declined (non-JSON `params`, an unavailable
action id, or a surface with no side panel). A decline is a return value, never
a thrown error, so a plugin registering several kinds of action can share one
open routine and branch on the result.

See the
[`composer-customization` reference plugin](../../examples/plugins/composer-customization/README.md)
for every region in one small app. The deprecated pre-1.0
Expand Down
Loading