feat(plugins): add an optional organization switcher slot (#13832)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins can add UI surfaces to the board.
> - Organization navigation is still fixed in the host sidebar.
> - A distribution needs a supported way to supply its own organization
menu.
> - This pull request adds one optional React slot with host-owned
navigation controls.
> - The built-in menu stays available when the optional contribution
cannot render.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin SDK, server capability validation, and sidebar UI.

**Problem or motivation**

An installed plugin cannot replace the organization switcher without
editing the host menu. Existing sidebar and overlay slots do not provide
this replacement surface.

**Proposed solution**

Add an `organizationSwitcher` slot that requires `ui.sidebar.register`.
Pass display state, an icon renderer, and navigation/logout callbacks.
Keep the built-in menu for absent, ambiguous, missing, failed, or
unsupported contributions.

**Roadmap alignment**

This keeps distribution UI in plugins and adds a small host contract. It
does not add an account system or change company authorization. The
maintainer requested this extension.

Related prior menu changes: #12788, #10917, and #10850. No duplicate
replacement-slot PR was found.

## What Changed

- Add the slot to shared validation, SDK types, and server capability
checks.
- Wrap both sidebar menu variants with the optional replacement.
- Resolve selection against the current account query before mounting,
and reset replacement state on account or company changes.
- Add host-specific component props and a fallback to `PluginSlotMount`.
- Document the React-only contract and its trust boundary.
- Report runtime-supervisor fixture startup details when CI readiness
fails.

## Verification

- `pnpm -r typecheck` and `pnpm build` passed.
- `pnpm check:token-gates` passed.
- `pnpm exec vitest run --project @paperclipai/ui`: 6,580 tests passed.
- Targeted manifest, replacement, and built-in menu tests: 26 passed,
including the incoming-account selection regression.
- Installed a local test contribution into an isolated server. CLI
inspection reported `ready`. Browser checks covered the loaded
production UI, keyboard dismissal, current-organization selection, and
an expired remote session. Remote account responses were fixtures.
- `pnpm test:run` was attempted. Its first server phase passed 8,323
tests but failed in 36 files due to embedded PostgreSQL startup and
filesystem permission errors on this Mac. Later phases did not run. The
current Linux CI run is green: 54 checks passed and two were skipped,
including build, typecheck, and browser gates. See
https://github.com/paperclipai/paperclip/actions/runs/35796722770.
- The earlier runtime-supervisor readiness failure did not reproduce
locally. The complete affected shard passed locally: 58 files and 843
tests. The six supervisor tests also passed on Node 24.21.0 with CI
flags. Added fixture startup diagnostics for the selected Node
executable and listener port. The affected Linux shard then passed all
843 tests. The original root cause remains unconfirmed; no production
runtime behavior or timeout was changed.

## Risks

- Plugin UI remains trusted same-origin code. Display props do not
authorize account requests.
- A replacement can change navigation behavior. The host retains the
built-in menu when discovery or rendering fails and keeps logout/session
cleanup host-owned.
- No database migration. Existing menus and portfolio behavior remain
available.
- Local full-suite verification is limited by the environment failures
listed above.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
execution, and browser testing. The runtime does not expose a more
specific model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-22 16:39:11 -07:00
1 parent f447990d77
commit 7badae6981
14 files changed
+249 -5

No files matched your search

+16
View File
@@ -369,6 +369,7 @@ Mount surfaces currently wired in the host include:
- `projectSidebarItem`
- `globalToolbarButton`
- `appShellOverlay` (persistent, signed-in application shell)
- `organizationSwitcher` (one React contribution replacing the organization menu)
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
@@ -617,3 +618,18 @@ pnpm build
For image-supplied plugins and the persistent shell lifecycle, see
[Distribution plugins](DISTRIBUTION-PLUGINS.md).
### Organization switcher
Declare one `organizationSwitcher` slot with `ui.sidebar.register`. The host
passes `PluginOrganizationSwitcherProps`: current company display data, collapsed
and open state, navigation/logout callbacks, and an icon renderer. Use the host
logout callback; authenticate remote account requests at their owning service.
`currentCompany` describes the host-local company. A distribution plugin must
resolve its external account/organization label itself; the host does not fetch
that portfolio on the plugin's behalf.
The slot props and `useHostContext()` are display context, not proof of identity.
The host resets plugin state on account/company changes and keeps its built-in
menu when no unique contribution exists, discovery fails, the module is missing,
or rendering throws. The slot is a React-only contract; do not use a custom
element export. This replaces only the menu, not company policy or authorization.
+1
View File
@@ -152,6 +152,7 @@ export type {
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
PluginOrganizationSwitcherProps,
PluginRouteSidebarProps,
PluginProjectSidebarItemProps,
PluginCommentAnnotationProps,
+21
View File
@@ -16,6 +16,7 @@
import type {
AnchorHTMLAttributes,
ReactNode,
MouseEvent as ReactMouseEvent,
} from "react";
import type {
@@ -270,6 +271,26 @@ export interface PluginDetailTabProps {
};
}
/** A single installed contribution replaces the organization menu. The host
* retains its built-in menu while loading, on ambiguity, or on render failure.
* These values/callbacks are presentation context, never authorization. */
export interface PluginOrganizationSwitcherProps {
organizationSwitcher: {
/** Host-local company branding, not an external account/organization label.
* Plugins resolve external organization identity at their owning service. */
currentCompany: { name: string; logoUrl: string | null } | null;
collapsed: boolean;
open: boolean;
onOpenChange(open: boolean): void;
/** Close mobile navigation before leaving the current organization. */
onNavigate(): void;
/** Host-owned logout preserves its session/cache invalidation behavior. */
onSignOut(): void;
signingOut: boolean;
renderIcon(name: string, logoUrl: string | null, inMenu: boolean): ReactNode;
};
}
/**
* Props passed to a plugin sidebar component.
*
+1
View File
@@ -1483,6 +1483,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"projectSidebarItem",
"globalToolbarButton",
"appShellOverlay",
"organizationSwitcher",
"toolbarButton",
"contextMenuItem",
"commentAnnotation",
@@ -54,7 +54,7 @@ describe("local service supervision", () => {
workspaceRuntime: {
services: [{
name: "web",
command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"",
command: "command -v node; node --version; node -e \"const http=require('node:http'); console.log('fixture starting', process.execPath, process.env.PORT); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1',()=>console.log('fixture listening', process.env.PORT))\"",
port: { type: "auto" },
readiness: {
type: "http",
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { pluginManifestV1Schema } from "@paperclipai/shared";
import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js";
describe("organization switcher installation", () => {
it("accepts the slot and requires the sidebar capability", () => {
const manifest = pluginManifestV1Schema.parse({
id: "example.account", apiVersion: 1, version: "1.0.0", displayName: "Account", categories: ["ui"],
description: "Organization navigation", author: "Example",
capabilities: ["ui.sidebar.register"], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
ui: { slots: [{ type: "organizationSwitcher", id: "organizations", displayName: "Organizations", exportName: "Organizations" }] },
});
const validator = pluginCapabilityValidator();
expect(validator.validateManifestCapabilities(manifest).allowed).toBe(true);
expect(validator.validateManifestCapabilities({ ...manifest, capabilities: [] })).toMatchObject({ allowed: false, missing: ["ui.sidebar.register"] });
});
});
@@ -164,6 +164,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
dashboardWidget: "ui.dashboardWidget.register",
globalToolbarButton: "ui.action.register",
appShellOverlay: "ui.action.register",
organizationSwitcher: "ui.sidebar.register",
toolbarButton: "ui.action.register",
contextMenuItem: "ui.action.register",
commentAnnotation: "ui.commentAnnotation.register",
+1
View File
@@ -74,6 +74,7 @@
"yjs": "13.6.32"
},
"devDependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@sentry/browser": "10.71.0",
"@storybook/addon-a11y": "10.5.10",
"@storybook/addon-docs": "10.5.10",
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui";
import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher";
import { registerPluginReactComponent, registerPluginWebComponent, type ResolvedPluginSlot } from "@/plugins/slots";
const state = vi.hoisted(() => ({ userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null, mobile: false, collapsed: false, close: vi.fn(), signOut: vi.fn(), props: null as PluginOrganizationSwitcherProps | null }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => state, useCompanyListQuery: () => ({ isSuccess: state.companyListReady, data: { unauthorized: false, companies: state.companyIds.map(id => ({ id, name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" })) } }) }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: state.userId } }) } }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId, selectedCompany: { name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" } }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: state.mobile, setSidebarOpen: state.close, collapsed: state.collapsed, peeking: false }) }));
vi.mock("@/hooks/useSignOut", () => ({ useSignOut: () => ({ mutate: state.signOut, isPending: false }) }));
vi.mock("./CompanyPatternIcon", () => ({ CompanyPatternIcon: () => <span>icon</span> }));
vi.mock("@/plugins/slots", async (importOriginal) => ({ ...await importOriginal<object>(), usePluginSlots: () => state }));
const slot: ResolvedPluginSlot = { type: "organizationSwitcher", id: "switcher", displayName: "Organizations", exportName: "Switcher", pluginId: "plugin-1", pluginKey: "fixture.switcher", pluginDisplayName: "Fixture", pluginVersion: "1" };
let root: Root | undefined;
let container: HTMLDivElement;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function render() {
if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }
flushSync(() => root!.render(<QueryClientProvider client={client}><PluginOrganizationSwitcher><button>Built-in organizations</button></PluginOrganizationSwitcher></QueryClientProvider>));
}
afterEach(() => {
if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); client.clear(); vi.restoreAllMocks();
Object.assign(state, { userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [], errorMessage: null, mobile: false, collapsed: false, props: null });
state.close.mockClear(); state.signOut.mockClear();
});
function register() {
registerPluginReactComponent(slot.pluginKey, slot.exportName, (props) => {
const host = (props as unknown as PluginOrganizationSwitcherProps).organizationSwitcher;
state.props = { organizationSwitcher: host };
const [selected, setSelected] = useState(false);
return <button onClick={() => setSelected(true)}>{selected ? "selected" : "Private organizations"}</button>;
});
state.slots = [slot];
}
describe("organization navigation replacement", () => {
it("keeps built-in navigation for absent, ambiguous, failed or unsettled discovery", () => {
render(); expect(container.textContent).toBe("Built-in organizations");
register(); state.slots = [slot, { ...slot, id: "other" }]; render(); expect(container.textContent).toBe("Built-in organizations");
state.slots = [slot]; state.errorMessage = "offline"; render(); expect(container.textContent).toBe("Built-in organizations");
state.errorMessage = null; state.settled = false; render(); expect(container.textContent).toBe("Built-in organizations");
});
it("falls back when a declared module is missing or rendering fails", () => {
state.slots = [{ ...slot, exportName: "Missing" }]; render(); expect(container.textContent).toBe("Built-in organizations");
vi.spyOn(console, "error").mockImplementation(() => {});
registerPluginReactComponent(slot.pluginKey, "Broken", () => { throw new Error("render failed"); });
state.slots = [{ ...slot, exportName: "Broken" }]; render(); expect(container.textContent).toBe("Built-in organizations");
});
it("does not mount a new account with the previous account's company selection", () => {
register(); render();
state.userId = "bob";
state.companyListReady = false;
state.props = null;
render();
expect(container.textContent).toBe("Built-in organizations");
expect(state.props).toBeNull();
state.companyListReady = true;
state.companyIds = ["company-b"];
render();
expect(container.textContent).toBe("Built-in organizations");
expect(state.props).toBeNull();
state.companyId = "company-b";
render();
expect(container.textContent).toBe("Private organizations");
});
it("keeps required navigation when the replacement cannot accept React callbacks", () => {
registerPluginWebComponent(slot.pluginKey, "WebSwitcher", "fixture-switcher");
state.slots = [{ ...slot, exportName: "WebSwitcher" }];
render();
expect(container.textContent).toBe("Built-in organizations");
});
it("renders one replacement, keeps host controls, and clears state across identity changes", () => {
register(); state.mobile = true; state.collapsed = true; render();
expect(container.textContent).toBe("Private organizations");
const host = state.props!.organizationSwitcher;
expect(host.currentCompany).toEqual({ name: "Acme", logoUrl: "/logo" }); expect(host.collapsed).toBe(true);
flushSync(() => host.onOpenChange(true)); expect(state.props!.organizationSwitcher.open).toBe(true);
flushSync(() => host.onNavigate()); expect(state.close).toHaveBeenCalledWith(false); expect(state.props!.organizationSwitcher.open).toBe(false);
host.onSignOut(); expect(state.signOut).toHaveBeenCalledOnce();
flushSync(() => container.querySelector("button")!.click()); render(); expect(container.textContent).toBe("selected");
state.userId = "bob"; render(); expect(container.textContent).toBe("Private organizations");
flushSync(() => container.querySelector("button")!.click()); state.companyId = "company-b"; render(); expect(container.textContent).toBe("Private organizations");
});
});
@@ -0,0 +1,64 @@
import { useState, type ReactNode } from "react";
import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui";
import { useAccountIdentity, useCompanyListQuery } from "@/api/companies-query";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { useSignOut } from "@/hooks/useSignOut";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
/** Optional replacement; the built-in menu stays usable throughout rollout. */
export function PluginOrganizationSwitcher({ children, open: controlledOpen, onOpenChange }: {
children: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const { userId, settled } = useAccountIdentity();
const { selectedCompanyId } = useCompany();
const companyList = useCompanyListQuery();
// Selection is component state and can outlive its account until an effect
// clears it. Resolve it against this account's query before mounting plugins.
const selectedCompany = companyList.data?.companies.find(company => company.id === selectedCompanyId) ?? null;
const contextSettled = settled && companyList.isSuccess && !companyList.data?.unauthorized
&& (selectedCompanyId === null || selectedCompany !== null);
return (
<OrganizationSwitcher key={JSON.stringify([userId, selectedCompanyId])}
settled={contextSettled} companyId={selectedCompanyId}
company={selectedCompany} open={controlledOpen} onOpenChange={onOpenChange}>
{children}
</OrganizationSwitcher>
);
}
function OrganizationSwitcher({ children, settled, companyId, company, open: controlledOpen, onOpenChange }: {
children: ReactNode; settled: boolean; companyId: string | null;
company: { name: string; issuePrefix: string; logoUrl?: string | null } | null;
open?: boolean; onOpenChange?: (open: boolean) => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
function closeNavigation() {
setOpen(false);
if (isMobile) setSidebarOpen(false);
}
const signOut = useSignOut({ onSignedOut: closeNavigation });
const { slots, errorMessage } = usePluginSlots({ slotTypes: ["organizationSwitcher"], companyId, enabled: settled });
// Never choose an arbitrary winner for a replacement surface.
if (!settled || errorMessage || slots.length !== 1) return children;
const props: PluginOrganizationSwitcherProps = {
organizationSwitcher: {
currentCompany: company ? { name: company.name, logoUrl: company.logoUrl ?? null } : null,
collapsed: collapsed && !peeking, open, onOpenChange: setOpen,
onNavigate: closeNavigation, onSignOut: () => signOut.mutate(), signingOut: signOut.isPending,
renderIcon: (name, logoUrl, inMenu) => <CompanyPatternIcon companyName={name} logoUrl={logoUrl}
className={inMenu
? "size-(--organization-popover-avatar-size) shrink-0 rounded-lg text-(length:--text-micro)"
: "size-5 shrink-0 rounded-md text-(length:--text-micro)"} />,
},
};
return <PluginSlotMount slot={slots[0]!}
context={{ companyId, companyPrefix: company?.issuePrefix ?? null }}
componentProps={{ ...props }} fallback={children} />;
}
@@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -212,7 +214,15 @@ function SortableCompanyItem({
);
}
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) {
return (
<PluginOrganizationSwitcher {...props}>
<BuiltinCompanyMenu {...props} />
</PluginOrganizationSwitcher>
);
}
function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isEditingOrder, setIsEditingOrder] = useState(false);
const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } =
@@ -9,6 +9,8 @@ import { queryKeys } from "@/lib/queryKeys";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production";
vi.mock("./PluginOrganizationSwitcher", () => ({ PluginOrganizationSwitcher: ({ children }: { children: ReactNode }) => children }));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
signInEmail: vi.fn(),
+11 -1
View File
@@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon";
import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher";
interface SidebarCompanyMenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
@@ -212,7 +214,15 @@ function SortableCompanyItem({
);
}
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) {
return (
<PluginOrganizationSwitcher {...props}>
<BuiltinCompanyMenu {...props} />
</PluginOrganizationSwitcher>
);
}
function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isEditingOrder, setIsEditingOrder] = useState(false);
const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } =
+13 -2
View File
@@ -701,6 +701,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult {
type PluginSlotErrorBoundaryProps = {
slot: ResolvedPluginSlot;
fallback?: ReactNode;
className?: string;
children: ReactNode;
};
@@ -728,6 +729,7 @@ class PluginSlotErrorBoundary extends Component<PluginSlotErrorBoundaryProps, Pl
override render() {
if (this.state.hasError) {
if (this.props.fallback !== undefined) return this.props.fallback;
return (
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", this.props.className)}>
{this.props.slot.pluginDisplayName}: failed to render
@@ -770,6 +772,10 @@ type PluginSlotMountProps = {
context: PluginSlotContext;
className?: string;
missingBehavior?: "hidden" | "placeholder";
/** Host-specific props; slot/context cannot be overridden. */
componentProps?: Record<string, unknown>;
/** Preserve required host navigation if an optional component is unavailable. */
fallback?: ReactNode;
};
/**
@@ -829,6 +835,8 @@ export function PluginSlotMount({
context,
className,
missingBehavior = "hidden",
componentProps,
fallback,
}: PluginSlotMountProps) {
usePluginRegistrySubscription();
const [, forceRerender] = useState(0);
@@ -852,6 +860,7 @@ export function PluginSlotMount({
}, [component, slot.pluginId]);
if (!component) {
if (fallback !== undefined) return fallback;
if (missingBehavior === "hidden") return null;
return (
<div className={cn("rounded-md border border-dashed border-border px-2 py-1 text-xs text-muted-foreground", className)}>
@@ -861,9 +870,9 @@ export function PluginSlotMount({
}
if (component.kind === "react") {
const node = createElement(component.component, { slot, context });
const node = createElement(component.component, { ...componentProps, slot, context });
return (
<PluginSlotErrorBoundary slot={slot} className={className}>
<PluginSlotErrorBoundary key={`${slot.pluginId}:${slot.pluginVersion}:${slot.id}`} slot={slot} className={className} fallback={fallback}>
<PluginBridgeScope pluginId={slot.pluginId} context={context}>
{className ? <div className={className}>{node}</div> : node}
</PluginBridgeScope>
@@ -871,6 +880,8 @@ export function PluginSlotMount({
);
}
if (componentProps && fallback !== undefined) return fallback;
return (
<PluginSlotErrorBoundary slot={slot} className={className}>
<PluginWebComponentMount