2026-03-13 16:22:34 -05:00
/ * *
* @fileoverview Plugin UI slot system — dynamic loading , error isolation ,
* and rendering of plugin - contributed UI extensions .
*
* Provides :
* - ` usePluginSlots(type, context?) ` — React hook that discovers and
* filters plugin UI contributions for a given slot type .
* - ` PluginSlotOutlet ` — renders all matching slots inline with error
* boundary isolation per plugin .
* - ` PluginBridgeScope ` — wraps each plugin ' s component tree to inject
* the bridge context ( ` pluginId ` , host context ) needed by bridge hooks .
*
* Plugin UI modules are loaded via dynamic ESM ` import() ` from the host ' s
* static file server ( ` /_plugins/:pluginId/ui/:entryFile ` ) . Each module
* exports named React components that correspond to ` ui.slots[].exportName `
* in the manifest .
*
* @see PLUGIN_SPEC . md § 19 — UI Extension Model
* @see PLUGIN_SPEC . md § 19.0 . 3 — Bundle Serving
* /
import {
Component ,
createElement ,
useEffect ,
useMemo ,
useRef ,
useState ,
type ErrorInfo ,
type ReactNode ,
type ComponentType ,
} from "react" ;
import { useQuery } from "@tanstack/react-query" ;
import type {
PluginLauncherDeclaration ,
PluginUiSlotDeclaration ,
PluginUiSlotEntityType ,
PluginUiSlotType ,
} from "@paperclipai/shared" ;
import { pluginsApi , type PluginUiContribution } from "@/api/plugins" ;
import { authApi } from "@/api/auth" ;
import { queryKeys } from "@/lib/queryKeys" ;
import { cn } from "@/lib/utils" ;
import {
PluginBridgeContext ,
type PluginHostContext ,
} from "./bridge" ;
export type PluginSlotContext = {
companyId? : string | null ;
companyPrefix? : string | null ;
projectId? : string | null ;
entityId? : string | null ;
entityType? : PluginUiSlotEntityType | null ;
/** Parent entity ID for nested slots (e.g. comment annotations within an issue). */
parentEntityId? : string | null ;
projectRef? : string | null ;
} ;
export type ResolvedPluginSlot = PluginUiSlotDeclaration & {
pluginId : string ;
pluginKey : string ;
pluginDisplayName : string ;
pluginVersion : string ;
} ;
Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 07:42:57 -05:00
/ * *
* Returns the unique ` routeSidebar ` slot that pairs with a single ` page ` slot
* for the given route , or ` null ` if no unambiguous pairing exists .
*
* Used to detect when a route is taken over by a plugin ' s full - page sidebar so
* host chrome ( breadcrumb , in - page Back ) can be suppressed .
* /
export function resolveRouteSidebarSlot (
slots : ResolvedPluginSlot [ ] ,
routePath : string | null ,
) : ResolvedPluginSlot | null {
if ( ! routePath ) return null ;
const pageMatches = slots . filter ( ( slot ) = > slot . type === "page" && slot . routePath === routePath ) ;
if ( pageMatches . length !== 1 ) return null ;
const [ pageSlot ] = pageMatches ;
const sidebarMatches = slots . filter ( ( slot ) = >
slot . type === "routeSidebar"
&& slot . routePath === routePath
&& slot . pluginId === pageSlot . pluginId ,
) ;
if ( sidebarMatches . length !== 1 ) return null ;
return sidebarMatches [ 0 ] ? ? null ;
}
2026-03-13 16:22:34 -05:00
type PluginSlotComponentProps = {
slot : ResolvedPluginSlot ;
context : PluginSlotContext ;
} ;
export type RegisteredPluginComponent =
| {
kind : "react" ;
component : ComponentType < PluginSlotComponentProps > ;
}
| {
kind : "web-component" ;
tagName : string ;
} ;
type SlotFilters = {
slotTypes : PluginUiSlotType [ ] ;
entityType? : PluginUiSlotEntityType | null ;
companyId? : string | null ;
enabled? : boolean ;
} ;
type UsePluginSlotsResult = {
slots : ResolvedPluginSlot [ ] ;
isLoading : boolean ;
errorMessage : string | null ;
} ;
/ * *
* In - memory registry for plugin UI exports loaded by the host page .
* Keys are ` ${ pluginKey } : ${ exportName } ` to match manifest slot declarations .
* /
const registry = new Map < string , RegisteredPluginComponent > ( ) ;
function buildRegistryKey ( pluginKey : string , exportName : string ) : string {
return ` ${ pluginKey } : ${ exportName } ` ;
}
function requiresEntityType ( slotType : PluginUiSlotType ) : boolean {
2026-03-14 15:05:04 -07:00
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton" ;
2026-03-13 16:22:34 -05:00
}
function getErrorMessage ( error : unknown ) : string {
if ( error instanceof Error && error . message ) return error . message ;
return "Unknown error" ;
}
/ * *
* Registers a React component export for a plugin UI slot .
* /
export function registerPluginReactComponent (
pluginKey : string ,
exportName : string ,
component : ComponentType < PluginSlotComponentProps > ,
) : void {
registry . set ( buildRegistryKey ( pluginKey , exportName ) , {
kind : "react" ,
component ,
} ) ;
}
/ * *
* Registers a custom element tag for a plugin UI slot .
* /
export function registerPluginWebComponent (
pluginKey : string ,
exportName : string ,
tagName : string ,
) : void {
registry . set ( buildRegistryKey ( pluginKey , exportName ) , {
kind : "web-component" ,
tagName ,
} ) ;
}
function resolveRegisteredComponent ( slot : ResolvedPluginSlot ) : RegisteredPluginComponent | null {
return registry . get ( buildRegistryKey ( slot . pluginKey , slot . exportName ) ) ? ? null ;
}
export function resolveRegisteredPluginComponent (
pluginKey : string ,
exportName : string ,
) : RegisteredPluginComponent | null {
return registry . get ( buildRegistryKey ( pluginKey , exportName ) ) ? ? null ;
}
// ---------------------------------------------------------------------------
// Plugin module dynamic import loader
// ---------------------------------------------------------------------------
type PluginLoadState = "idle" | "loading" | "loaded" | "error" ;
/ * *
* Tracks the load state for each plugin ' s UI module by contribution cache key .
*
* Once a plugin module is loaded , all its named exports are inspected and
* registered into the component ` registry ` so that ` resolveRegisteredComponent `
* can find them when slots render .
* /
const pluginLoadStates = new Map < string , PluginLoadState > ( ) ;
/ * *
* Promise cache to prevent concurrent duplicate imports for the same plugin .
* /
const inflightImports = new Map < string , Promise < void > > ( ) ;
/ * *
* Build the full URL for a plugin ' s UI entry module .
*
* The server serves plugin UI bundles at ` /_plugins/:pluginId/ui/* ` .
* The ` uiEntryFile ` from the contribution ( typically ` "index.js" ` ) is
* appended to form the complete import path .
* /
function buildPluginModuleKey ( contribution : PluginUiContribution ) : string {
const cacheHint = contribution . updatedAt ? ? contribution . version ? ? "0" ;
return ` ${ contribution . pluginId } : ${ cacheHint } ` ;
}
function buildPluginUiUrl ( contribution : PluginUiContribution ) : string {
const cacheHint = encodeURIComponent ( contribution . updatedAt ? ? contribution . version ? ? "0" ) ;
return ` /_plugins/ ${ encodeURIComponent ( contribution . pluginId ) } /ui/ ${ contribution . uiEntryFile } ?v= ${ cacheHint } ` ;
}
/ * *
* Import a plugin ' s UI entry module with bare - specifier rewriting .
*
* Plugin bundles are built with ` external: ["@paperclipai/plugin-sdk/ui", "react", "react-dom"] ` ,
* so their ESM output contains bare specifier imports like :
*
* ` ` ` js
* import { usePluginData } from "@paperclipai/plugin-sdk/ui" ;
* import React from "react" ;
* ` ` `
*
* Browsers cannot resolve bare specifiers without an import map . Rather than
* fighting import map timing constraints , we :
* 1 . Fetch the module source text
* 2 . Rewrite bare specifier imports to use blob URLs that re - export from the
* host ' s global bridge registry ( ` globalThis.__paperclipPluginBridge__ ` )
* 3 . Import the rewritten module via a blob URL
*
* This approach is compatible with all modern browsers and avoids import map
* ordering issues .
* /
const shimBlobUrls : Record < string , string > = { } ;
function applyJsxRuntimeKey (
props : Record < string , unknown > | null | undefined ,
key : string | number | undefined ,
) : Record < string , unknown > {
if ( key === undefined ) return props ? ? { } ;
return { . . . ( props ? ? { } ) , key } ;
}
function getShimBlobUrl ( specifier : "react" | "react-dom" | "react-dom/client" | "react/jsx-runtime" | "sdk-ui" ) : string {
if ( shimBlobUrls [ specifier ] ) return shimBlobUrls [ specifier ] ;
let source : string ;
switch ( specifier ) {
case "react" :
source = `
const R = globalThis . __paperclipPluginBridge__ ? . react ;
export default R ;
const { useState , useEffect , useCallback , useMemo , useRef , useContext ,
createContext , createElement , Fragment , Component , forwardRef ,
memo , lazy , Suspense , StrictMode , cloneElement , Children ,
isValidElement , createRef } = R ;
export { useState , useEffect , useCallback , useMemo , useRef , useContext ,
createContext , createElement , Fragment , Component , forwardRef ,
memo , lazy , Suspense , StrictMode , cloneElement , Children ,
isValidElement , createRef } ;
` ;
break ;
case "react/jsx-runtime" :
source = `
const R = globalThis . __paperclipPluginBridge__ ? . react ;
const withKey = $ { applyJsxRuntimeKey . toString ( ) } ;
export const jsx = ( type , props , key ) = > R . createElement ( type , withKey ( props , key ) ) ;
export const jsxs = ( type , props , key ) = > R . createElement ( type , withKey ( props , key ) ) ;
export const Fragment = R . Fragment ;
` ;
break ;
case "react-dom" :
case "react-dom/client" :
source = `
const RD = globalThis . __paperclipPluginBridge__ ? . reactDom ;
export default RD ;
const { createRoot , hydrateRoot , createPortal , flushSync } = RD ? ? { } ;
export { createRoot , hydrateRoot , createPortal , flushSync } ;
` ;
break ;
case "sdk-ui" :
source = `
const SDK = globalThis . __paperclipPluginBridge__ ? . sdkUi ? ? { } ;
Expand plugin host surface (#5205)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports
## What Changed
- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.
## Verification
- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.
## Risks
- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 07:42:57 -05:00
function missing ( name ) {
return function MissingPaperclipSdkUiComponent() {
throw new Error ( 'Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.' ) ;
} ;
}
const { usePluginData , usePluginAction , useHostContext , useHostLocation , useHostNavigation , usePluginStream , usePluginToast } = SDK ;
const MetricCard = SDK . MetricCard ? ? missing ( "MetricCard" ) ;
const StatusBadge = SDK . StatusBadge ? ? missing ( "StatusBadge" ) ;
const DataTable = SDK . DataTable ? ? missing ( "DataTable" ) ;
const TimeseriesChart = SDK . TimeseriesChart ? ? missing ( "TimeseriesChart" ) ;
const MarkdownBlock = SDK . MarkdownBlock ? ? missing ( "MarkdownBlock" ) ;
const MarkdownEditor = SDK . MarkdownEditor ? ? missing ( "MarkdownEditor" ) ;
const KeyValueList = SDK . KeyValueList ? ? missing ( "KeyValueList" ) ;
const ActionBar = SDK . ActionBar ? ? missing ( "ActionBar" ) ;
const LogView = SDK . LogView ? ? missing ( "LogView" ) ;
const JsonTree = SDK . JsonTree ? ? missing ( "JsonTree" ) ;
const Spinner = SDK . Spinner ? ? missing ( "Spinner" ) ;
const ErrorBoundary = SDK . ErrorBoundary ? ? missing ( "ErrorBoundary" ) ;
const FileTree = SDK . FileTree ? ? missing ( "FileTree" ) ;
const IssuesList = SDK . IssuesList ? ? missing ( "IssuesList" ) ;
const AssigneePicker = SDK . AssigneePicker ? ? missing ( "AssigneePicker" ) ;
const ProjectPicker = SDK . ProjectPicker ? ? missing ( "ProjectPicker" ) ;
const ManagedRoutinesList = SDK . ManagedRoutinesList ? ? missing ( "ManagedRoutinesList" ) ;
export { usePluginData , usePluginAction , useHostContext , useHostLocation , useHostNavigation , usePluginStream , usePluginToast , MetricCard , StatusBadge , DataTable , TimeseriesChart , MarkdownBlock , MarkdownEditor , KeyValueList , ActionBar , LogView , JsonTree , Spinner , ErrorBoundary , FileTree , IssuesList , AssigneePicker , ProjectPicker , ManagedRoutinesList } ;
2026-03-13 16:22:34 -05:00
` ;
break ;
}
const blob = new Blob ( [ source ] , { type : "application/javascript" } ) ;
const url = URL . createObjectURL ( blob ) ;
shimBlobUrls [ specifier ] = url ;
return url ;
}
/ * *
* Rewrite bare specifier imports in an ESM source string to use blob URLs .
*
* This handles the standard import patterns emitted by esbuild / rollup :
* - ` import { ... } from "react"; `
* - ` import React from "react"; `
* - ` import * as React from "react"; `
* - ` import { ... } from "@paperclipai/plugin-sdk/ui"; `
*
* Also handles re - exports :
* - ` export { ... } from "react"; `
* /
function rewriteBareSpecifiers ( source : string ) : string {
// Build a mapping of bare specifiers to blob URLs.
const rewrites : Record < string , string > = {
'"@paperclipai/plugin-sdk/ui"' : ` " ${ getShimBlobUrl ( "sdk-ui" ) } " ` ,
"'@paperclipai/plugin-sdk/ui'" : ` ' ${ getShimBlobUrl ( "sdk-ui" ) } ' ` ,
'"@paperclipai/plugin-sdk/ui/hooks"' : ` " ${ getShimBlobUrl ( "sdk-ui" ) } " ` ,
"'@paperclipai/plugin-sdk/ui/hooks'" : ` ' ${ getShimBlobUrl ( "sdk-ui" ) } ' ` ,
'"react/jsx-runtime"' : ` " ${ getShimBlobUrl ( "react/jsx-runtime" ) } " ` ,
"'react/jsx-runtime'" : ` ' ${ getShimBlobUrl ( "react/jsx-runtime" ) } ' ` ,
'"react-dom/client"' : ` " ${ getShimBlobUrl ( "react-dom/client" ) } " ` ,
"'react-dom/client'" : ` ' ${ getShimBlobUrl ( "react-dom/client" ) } ' ` ,
'"react-dom"' : ` " ${ getShimBlobUrl ( "react-dom" ) } " ` ,
"'react-dom'" : ` ' ${ getShimBlobUrl ( "react-dom" ) } ' ` ,
'"react"' : ` " ${ getShimBlobUrl ( "react" ) } " ` ,
"'react'" : ` ' ${ getShimBlobUrl ( "react" ) } ' ` ,
} ;
let result = source ;
for ( const [ from , to ] of Object . entries ( rewrites ) ) {
// Only rewrite in import/export from contexts, not in arbitrary strings.
// The regex matches `from "..."` or `from '...'` patterns.
result = result . replaceAll ( ` from ${ from } ` , ` from ${ to } ` ) ;
// Also handle `import "..."` (side-effect imports)
result = result . replaceAll ( ` import ${ from } ` , ` import ${ to } ` ) ;
}
return result ;
}
/ * *
* Fetch , rewrite , and import a plugin UI module .
*
* @param url - The URL to the plugin ' s UI entry module
* @returns The module ' s exports
* /
async function importPluginModule ( url : string ) : Promise < Record < string , unknown > > {
// Check if the bridge registry is available. If not, fall back to direct
// import (which will fail on bare specifiers but won't crash the loader).
if ( ! globalThis . __paperclipPluginBridge__ ) {
console . warn ( "[plugin-loader] Bridge registry not initialized, falling back to direct import" ) ;
return import ( /* @vite-ignore */ url ) ;
}
// Fetch the module source text
const response = await fetch ( url ) ;
if ( ! response . ok ) {
throw new Error ( ` Failed to fetch plugin module: ${ response . status } ${ response . statusText } ` ) ;
}
const source = await response . text ( ) ;
// Rewrite bare specifier imports to blob URLs
const rewritten = rewriteBareSpecifiers ( source ) ;
// Create a blob URL from the rewritten source and import it
const blob = new Blob ( [ rewritten ] , { type : "application/javascript" } ) ;
const blobUrl = URL . createObjectURL ( blob ) ;
try {
const mod = await import ( /* @vite-ignore */ blobUrl ) ;
return mod ;
} finally {
// Clean up the blob URL after import (the module is already loaded)
URL . revokeObjectURL ( blobUrl ) ;
}
}
/ * *
* Dynamically import a plugin ' s UI entry module and register all named
* exports that look like React components ( functions or classes ) into the
* component registry .
*
* This replaces the previous approach where plugin bundles had to
* self - register via ` window.paperclipPlugins.registerReactComponent() ` .
* Now the host is responsible for importing the module and binding
* exports to the correct ` pluginKey:exportName ` registry keys .
*
* Plugin modules are loaded with bare - specifier rewriting so that imports
* of ` @paperclipai/plugin-sdk/ui ` , ` react ` , and ` react-dom ` resolve to the
* host - provided implementations via the bridge registry .
*
* Web - component registrations still work : if the module has a named export
* that matches an ` exportName ` declared in a slot AND that export is a
* string ( the custom element tag name ) , it ' s registered as a web component .
* /
async function loadPluginModule ( contribution : PluginUiContribution ) : Promise < void > {
const { pluginId , pluginKey , slots , launchers } = contribution ;
const moduleKey = buildPluginModuleKey ( contribution ) ;
// Already loaded or loading — return early.
const state = pluginLoadStates . get ( moduleKey ) ;
if ( state === "loaded" || state === "loading" ) {
// If currently loading, wait for the inflight promise.
const inflight = inflightImports . get ( pluginId ) ;
if ( inflight ) await inflight ;
return ;
}
// If another import for this plugin ID is currently in progress, wait for it.
const running = inflightImports . get ( pluginId ) ;
if ( running ) {
await running ;
const recheckedState = pluginLoadStates . get ( moduleKey ) ;
if ( recheckedState === "loaded" ) {
return ;
}
}
pluginLoadStates . set ( moduleKey , "loading" ) ;
const url = buildPluginUiUrl ( contribution ) ;
const importPromise = ( async ( ) = > {
try {
// Dynamic ESM import of the plugin's UI entry module with
// bare-specifier rewriting for host-provided dependencies.
const mod : Record < string , unknown > = await importPluginModule ( url ) ;
// Collect the set of export names declared across all UI contributions so
// we only register what the manifest advertises (ignore extra exports).
const declaredExports = new Set < string > ( ) ;
for ( const slot of slots ) {
declaredExports . add ( slot . exportName ) ;
}
for ( const launcher of launchers ) {
if ( launcher . exportName ) {
declaredExports . add ( launcher . exportName ) ;
}
if ( isLauncherComponentTarget ( launcher ) ) {
declaredExports . add ( launcher . action . target ) ;
}
}
for ( const exportName of declaredExports ) {
const exported = mod [ exportName ] ;
if ( exported === undefined ) {
console . warn (
` Plugin " ${ pluginKey } " declares slot export " ${ exportName } " but the module does not export it. ` ,
) ;
continue ;
}
if ( typeof exported === "function" ) {
// React component (function component or class component).
registerPluginReactComponent (
pluginKey ,
exportName ,
exported as ComponentType < PluginSlotComponentProps > ,
) ;
} else if ( typeof exported === "string" ) {
// Web component tag name.
registerPluginWebComponent ( pluginKey , exportName , exported ) ;
} else {
console . warn (
` Plugin " ${ pluginKey } " export " ${ exportName } " is neither a function nor a string tag name — skipping. ` ,
) ;
}
}
pluginLoadStates . set ( moduleKey , "loaded" ) ;
} catch ( err ) {
pluginLoadStates . set ( moduleKey , "error" ) ;
console . error ( ` Failed to load UI module for plugin " ${ pluginKey } " ` , err ) ;
} finally {
inflightImports . delete ( pluginId ) ;
}
} ) ( ) ;
inflightImports . set ( pluginId , importPromise ) ;
await importPromise ;
}
function isLauncherComponentTarget ( launcher : PluginLauncherDeclaration ) : boolean {
return launcher . action . type === "openModal"
|| launcher . action . type === "openDrawer"
|| launcher . action . type === "openPopover" ;
}
/ * *
* Load UI modules for a set of plugin contributions .
*
* Returns a promise that resolves once all modules have been loaded ( or
* failed ) . Plugins that are already loaded are skipped .
* /
async function ensurePluginModulesLoaded ( contributions : PluginUiContribution [ ] ) : Promise < void > {
await Promise . all (
contributions . map ( ( c ) = > loadPluginModule ( c ) ) ,
) ;
}
export async function ensurePluginContributionLoaded (
contribution : PluginUiContribution ,
) : Promise < void > {
await loadPluginModule ( contribution ) ;
}
/ * *
* Returns the aggregate load state across a set of plugin contributions .
* - If any plugin is still loading → "loading"
* - If all are loaded ( or no contributions ) → "loaded"
* - If all finished but some errored → "loaded" ( errors are logged , not fatal )
* /
function aggregateLoadState ( contributions : PluginUiContribution [ ] ) : "loading" | "loaded" {
for ( const c of contributions ) {
const state = pluginLoadStates . get ( buildPluginModuleKey ( c ) ) ;
if ( state === "loading" || state === "idle" || state === undefined ) {
return "loading" ;
}
}
return "loaded" ;
}
// ---------------------------------------------------------------------------
// React hooks
// ---------------------------------------------------------------------------
/ * *
* Trigger dynamic loading of plugin UI modules when contributions change .
*
* This hook is intentionally decoupled from usePluginSlots so that callers
* who consume slots via ` usePluginSlots() ` automatically get module loading
* without extra wiring .
* /
function usePluginModuleLoader ( contributions : PluginUiContribution [ ] | undefined ) {
const [ , setTick ] = useState ( 0 ) ;
useEffect ( ( ) = > {
if ( ! contributions || contributions . length === 0 ) return ;
// Filter to contributions that haven't been loaded yet.
const unloaded = contributions . filter ( ( c ) = > {
const state = pluginLoadStates . get ( buildPluginModuleKey ( c ) ) ;
return state !== "loaded" && state !== "loading" ;
} ) ;
if ( unloaded . length === 0 ) return ;
let cancelled = false ;
void ensurePluginModulesLoaded ( unloaded ) . then ( ( ) = > {
// Re-render so the slot mount can resolve the newly-registered components.
if ( ! cancelled ) setTick ( ( t ) = > t + 1 ) ;
} ) ;
return ( ) = > {
cancelled = true ;
} ;
} , [ contributions ] ) ;
}
/ * *
* Resolves and sorts slots across all ready plugin contributions .
*
* Filtering rules :
* - ` slotTypes ` must match one of the caller - requested host slot types .
* - Entity - scoped slot types ( ` detailTab ` , ` taskDetailView ` , ` contextMenuItem ` )
* require ` entityType ` and must include it in ` slot.entityTypes ` .
*
* Automatically triggers dynamic import of plugin UI modules for any
* newly - discovered contributions . Components render once loading completes .
* /
export function usePluginSlots ( filters : SlotFilters ) : UsePluginSlotsResult {
const queryEnabled = filters . enabled ? ? true ;
const { data , isLoading : isQueryLoading , error } = useQuery ( {
2026-03-13 16:58:29 -05:00
queryKey : queryKeys.plugins.uiContributions ,
queryFn : ( ) = > pluginsApi . listUiContributions ( ) ,
2026-03-13 16:22:34 -05:00
enabled : queryEnabled ,
} ) ;
// Kick off dynamic imports for any new plugin contributions.
usePluginModuleLoader ( data ) ;
const slotTypesKey = useMemo ( ( ) = > [ . . . filters . slotTypes ] . sort ( ) . join ( "|" ) , [ filters . slotTypes ] ) ;
const slots = useMemo ( ( ) = > {
const allowedTypes = new Set ( slotTypesKey . split ( "|" ) . filter ( Boolean ) as PluginUiSlotType [ ] ) ;
const rows : ResolvedPluginSlot [ ] = [ ] ;
for ( const contribution of data ? ? [ ] ) {
for ( const slot of contribution . slots ) {
if ( ! allowedTypes . has ( slot . type ) ) continue ;
if ( requiresEntityType ( slot . type ) ) {
if ( ! filters . entityType ) continue ;
if ( ! slot . entityTypes ? . includes ( filters . entityType ) ) continue ;
}
rows . push ( {
. . . slot ,
pluginId : contribution.pluginId ,
pluginKey : contribution.pluginKey ,
pluginDisplayName : contribution.displayName ,
pluginVersion : contribution.version ,
} ) ;
}
}
rows . sort ( ( a , b ) = > {
const ao = a . order ? ? Number . MAX_SAFE_INTEGER ;
const bo = b . order ? ? Number . MAX_SAFE_INTEGER ;
if ( ao !== bo ) return ao - bo ;
const pluginCmp = a . pluginDisplayName . localeCompare ( b . pluginDisplayName ) ;
if ( pluginCmp !== 0 ) return pluginCmp ;
return a . displayName . localeCompare ( b . displayName ) ;
} ) ;
return rows ;
} , [ data , filters . entityType , slotTypesKey ] ) ;
// Consider loading until both query and module imports are done.
const modulesLoaded = data ? aggregateLoadState ( data ) === "loaded" : true ;
const isLoading = queryEnabled && ( isQueryLoading || ! modulesLoaded ) ;
return {
slots ,
isLoading ,
errorMessage : error ? getErrorMessage ( error ) : null ,
} ;
}
type PluginSlotErrorBoundaryProps = {
slot : ResolvedPluginSlot ;
className? : string ;
children : ReactNode ;
} ;
type PluginSlotErrorBoundaryState = {
hasError : boolean ;
} ;
class PluginSlotErrorBoundary extends Component < PluginSlotErrorBoundaryProps , PluginSlotErrorBoundaryState > {
override state : PluginSlotErrorBoundaryState = { hasError : false } ;
static getDerivedStateFromError ( ) : PluginSlotErrorBoundaryState {
return { hasError : true } ;
}
override componentDidCatch ( error : unknown , info : ErrorInfo ) : void {
// Keep plugin failures isolated while preserving actionable diagnostics.
console . error ( "Plugin slot render failed" , {
pluginKey : this.props.slot.pluginKey ,
slotId : this.props.slot.id ,
error ,
info : info.componentStack ,
} ) ;
}
override render() {
if ( this . state . hasError ) {
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
< / div >
) ;
}
return this . props . children ;
}
}
function PluginWebComponentMount ( {
tagName ,
slot ,
context ,
className ,
} : {
tagName : string ;
slot : ResolvedPluginSlot ;
context : PluginSlotContext ;
className? : string ;
} ) {
const ref = useRef < HTMLElement | null > ( null ) ;
useEffect ( ( ) = > {
if ( ! ref . current ) return ;
// Bridge manifest slot/context metadata onto the custom element instance.
const el = ref . current as HTMLElement & {
pluginSlot? : ResolvedPluginSlot ;
pluginContext? : PluginSlotContext ;
} ;
el . pluginSlot = slot ;
el . pluginContext = context ;
} , [ context , slot ] ) ;
return createElement ( tagName , { ref , className } ) ;
}
type PluginSlotMountProps = {
slot : ResolvedPluginSlot ;
context : PluginSlotContext ;
className? : string ;
missingBehavior ? : "hidden" | "placeholder" ;
} ;
/ * *
* Maps the slot ' s ` PluginSlotContext ` to a ` PluginHostContext ` for the bridge .
*
* The bridge hooks need the full host context shape ; the slot context carries
* the subset available from the rendering location .
* /
function slotContextToHostContext (
pluginSlotContext : PluginSlotContext ,
userId : string | null ,
) : PluginHostContext {
return {
companyId : pluginSlotContext.companyId ? ? null ,
companyPrefix : pluginSlotContext.companyPrefix ? ? null ,
projectId : pluginSlotContext.projectId ? ? ( pluginSlotContext . entityType === "project" ? pluginSlotContext . entityId ? ? null : null ) ,
entityId : pluginSlotContext.entityId ? ? null ,
entityType : pluginSlotContext.entityType ? ? null ,
parentEntityId : pluginSlotContext.parentEntityId ? ? null ,
userId ,
renderEnvironment : null ,
} ;
}
/ * *
* Wrapper component that sets the active bridge context around plugin renders .
*
* This ensures that ` usePluginData() ` , ` usePluginAction() ` , and ` useHostContext() `
* have access to the current plugin ID and host context during the render phase .
* /
function PluginBridgeScope ( {
pluginId ,
context ,
children ,
} : {
pluginId : string ;
context : PluginSlotContext ;
children : ReactNode ;
} ) {
const { data : session } = useQuery ( {
queryKey : queryKeys.auth.session ,
queryFn : ( ) = > authApi . getSession ( ) ,
} ) ;
const userId = session ? . user ? . id ? ? session ? . session ? . userId ? ? null ;
const hostContext = useMemo ( ( ) = > slotContextToHostContext ( context , userId ) , [ context , userId ] ) ;
const value = useMemo ( ( ) = > ( { pluginId , hostContext } ) , [ pluginId , hostContext ] ) ;
return (
< PluginBridgeContext.Provider value = { value } >
{ children }
< / PluginBridgeContext.Provider >
) ;
}
export function PluginSlotMount ( {
slot ,
context ,
className ,
missingBehavior = "hidden" ,
} : PluginSlotMountProps ) {
const [ , forceRerender ] = useState ( 0 ) ;
const component = resolveRegisteredComponent ( slot ) ;
useEffect ( ( ) = > {
if ( component ) return ;
const inflight = inflightImports . get ( slot . pluginId ) ;
if ( ! inflight ) return ;
let cancelled = false ;
void inflight . finally ( ( ) = > {
if ( ! cancelled ) {
forceRerender ( ( tick ) = > tick + 1 ) ;
}
} ) ;
return ( ) = > {
cancelled = true ;
} ;
} , [ component , slot . pluginId ] ) ;
if ( ! component ) {
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 ) } >
{ slot . pluginDisplayName } : { slot . displayName }
< / div >
) ;
}
if ( component . kind === "react" ) {
const node = createElement ( component . component , { slot , context } ) ;
return (
< PluginSlotErrorBoundary slot = { slot } className = { className } >
< PluginBridgeScope pluginId = { slot . pluginId } context = { context } >
{ className ? < div className = { className } > { node } < / div > : node }
< / PluginBridgeScope >
< / PluginSlotErrorBoundary >
) ;
}
return (
< PluginSlotErrorBoundary slot = { slot } className = { className } >
< PluginWebComponentMount
tagName = { component . tagName }
slot = { slot }
context = { context }
className = { className }
/ >
< / PluginSlotErrorBoundary >
) ;
}
type PluginSlotOutletProps = {
slotTypes : PluginUiSlotType [ ] ;
context : PluginSlotContext ;
entityType? : PluginUiSlotEntityType | null ;
className? : string ;
itemClassName? : string ;
errorClassName? : string ;
missingBehavior ? : "hidden" | "placeholder" ;
} ;
export function PluginSlotOutlet ( {
slotTypes ,
context ,
entityType ,
className ,
itemClassName ,
errorClassName ,
missingBehavior = "hidden" ,
} : PluginSlotOutletProps ) {
const { slots , errorMessage } = usePluginSlots ( {
slotTypes ,
entityType ,
companyId : context.companyId ,
} ) ;
if ( errorMessage ) {
return (
< div className = { cn ( "rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive" , errorClassName ) } >
Plugin extensions unavailable : { errorMessage }
< / div >
) ;
}
if ( slots . length === 0 ) return null ;
return (
< div className = { className } >
{ slots . map ( ( slot ) = > (
< PluginSlotMount
key = { ` ${ slot . pluginKey } : ${ slot . id } ` }
slot = { slot }
context = { context }
className = { itemClassName }
missingBehavior = { missingBehavior }
/ >
) ) }
< / div >
) ;
}
// ---------------------------------------------------------------------------
// Test helpers — exported for use in test suites only.
// ---------------------------------------------------------------------------
/ * *
* Reset the module loader state . Only use in tests .
* @internal
* /
export function _resetPluginModuleLoader ( ) : void {
pluginLoadStates . clear ( ) ;
inflightImports . clear ( ) ;
registry . clear ( ) ;
if ( typeof URL . revokeObjectURL === "function" ) {
for ( const url of Object . values ( shimBlobUrls ) ) {
URL . revokeObjectURL ( url ) ;
}
}
for ( const key of Object . keys ( shimBlobUrls ) ) {
delete shimBlobUrls [ key ] ;
}
}
export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey ;
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers ;