mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board reads API data and keeps the current task on screen. > - A restart or temporary network failure can interrupt those reads. > - Separate page error handlers can hide cached data or expose raw server codes. > - The app needs one connection state, readable error copy and a consistent read policy. > - This change provides that foundation and moves affected read surfaces onto it. > - The dependent write layer in #17960 preserves edits and submissions across the same failures. ## Linked Issues or Issue Description **What happened?** Temporary API failures could replace loaded content with errors. Different surfaces handled the same outage differently. Some showed raw server codes. A successful query to an unrelated service could also falsely report that Paperclip had recovered. **Expected behavior** Keep cached content visible. Show one connection banner during sustained trouble. Confirm recovery through the Paperclip health probe or its WebSocket. Retry transient reads and show readable errors for permanent failures. **Steps to reproduce** 1. Open a populated task or workspace page. 2. Interrupt API requests with a 503 or a dropped connection. 3. Keep an unrelated external request in flight. 4. Confirm that cached content remains and that the external result cannot clear the outage. 5. Restore the API and check that reads refresh. The write layer first merged into this feature branch in #15769 now has its own dependent PR, #17960. This PR contains the shared foundation and read changes. Merge this PR first. ## What Changed - Classify API errors and provide readable error copy, including non-JSON proxy responses and Retry-After handling. - Use one connection store and health probe with bounded backoff. Confirm recovery from the server health probe or live-updates WebSocket. - Pause reads while disconnected, retry transient errors, and refresh active reads after recovery. - Keep cached data visible through shared query views and affected page, panel and plugin read surfaces. - Add one app-level connection banner and support the pending-write count consumed by the dependent write layer. - Add a query-error rendering guard. Call it directly from the existing CI step. Update development documentation. ## Verification - Historical-chat lookup now follows the same ready/stale roster state as rendering. Three regressions fail before the fix; the six new cases pass after it, including working Retry, fresh data, no-cache error, permanent 403 and company isolation. All six focused query/recovery suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no unresolved findings. Canary Dry Run initially refused a preview version that was already on npm. It passed after one rerun with fresh registry metadata; no check was disabled. - Integrated current master and retained both transcript interest registration and connectivity reporting on socket open. The seven focused recovery/query suites pass 288 tests, and UI typecheck and production build pass. All 9,451 UI tests across 799 files pass on the merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved findings. - Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI checks pass. Apex is **5/5** on that head with no unresolved findings. The branch has no merge conflict. GitHub still requires code-owner approval. - The foundation passes 9,430 UI tests in 804 files. After integrating current master page paths, 190 focused page tests pass. - Two unrelated-success regressions fail before the latest fix. With the fix, 62 focused UI tests, all six guard tests and UI TypeScript pass. - The composed foundation and write layer pass **9,505 UI tests in 808 files**, UI TypeScript, a production UI build, and query-error, token and map guards. - A real Chromium journey on the composed source injects a 503 during title save. The loaded task stays visible; one reconnect banner appears; the edit saves after recovery; another task stays unchanged; the saved title survives reload. - Full recursive local checks were not repeated because Runner compilation exhausted local disk during another repair. Hosted checks must pass before merge. No new staging deployment is claimed. - Latest review follow-up: a cached CaseDetail now survives a failed HTTP 429 refresh. Its page regression fails before the fix and passes after it. Eighty focused foundation tests, UI TypeScript, build and guards pass after that change. - Earlier CI evidence is retained: several foundation jobs received a runner shutdown signal together. The write-layer head also failed an unchanged Runner descriptor close test with `provider_transport_failed`. The same Runner source passed that suite in the foundation run. New CI runs cover the updated heads; no check or assertion was weakened. ## Risks - Shared retry defaults change the time before transient read errors appear. Permanent errors still fail promptly. - ApiError.message now contains readable copy. Callers that compare raw codes must use ApiError.code. - Cache success can come from another service or local data. It must not confirm Paperclip recovery. - Durable writes are reviewed separately in #17960. This PR does not change database or mutation contracts. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, tests and review fixes. The Codex session does not expose its exact serving 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: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
995 lines
34 KiB
TypeScript
995 lines
34 KiB
TypeScript
/**
|
|
* @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,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ErrorInfo,
|
|
type ReactNode,
|
|
type ComponentType,
|
|
} from "react";
|
|
import * as ReactModule 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 { describeError, isTransientError } from "@/api/errors";
|
|
import { queryViewKind } from "@/components/QueryView";
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
type PluginSlotComponentProps = {
|
|
slot: ResolvedPluginSlot;
|
|
context: PluginSlotContext;
|
|
};
|
|
|
|
export type RegisteredPluginComponent =
|
|
| {
|
|
kind: "react";
|
|
component: ComponentType<PluginSlotComponentProps>;
|
|
}
|
|
| {
|
|
kind: "web-component";
|
|
tagName: string;
|
|
};
|
|
|
|
type SlotFilters = {
|
|
slotTypes: PluginUiSlotType[];
|
|
taskSidePanel?: boolean;
|
|
entityType?: PluginUiSlotEntityType | null;
|
|
companyId?: string | null;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
type UsePluginSlotsResult = {
|
|
/** The last loaded slots. Kept through a transient failure so chrome never disappears. */
|
|
slots: ResolvedPluginSlot[];
|
|
/** True while nothing has loaded yet, including while reconnecting after an outage. */
|
|
isLoading: boolean;
|
|
/**
|
|
* Readable copy for a non-transient failure (`describeError`), or null. A
|
|
* transient failure never sets this: outlets collapse or render the last
|
|
* loaded slots, and the app-level connection banner explains the outage.
|
|
*/
|
|
errorMessage: string | null;
|
|
error: unknown;
|
|
retry: () => void;
|
|
};
|
|
|
|
/**
|
|
* 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>();
|
|
const registryListeners = new Set<() => void>();
|
|
|
|
function buildRegistryKey(pluginKey: string, exportName: string): string {
|
|
return `${pluginKey}:${exportName}`;
|
|
}
|
|
|
|
function notifyRegistryListeners(): void {
|
|
for (const listener of registryListeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
function usePluginRegistrySubscription(): void {
|
|
const [, forceRerender] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const listener = () => forceRerender((tick) => tick + 1);
|
|
registryListeners.add(listener);
|
|
return () => {
|
|
registryListeners.delete(listener);
|
|
};
|
|
}, []);
|
|
}
|
|
|
|
function requiresEntityType(slotType: PluginUiSlotType): boolean {
|
|
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton";
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
notifyRegistryListeners();
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
notifyRegistryListeners();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function isRegisterablePluginExport(exported: unknown): boolean {
|
|
return typeof exported === "function" || typeof exported === "string";
|
|
}
|
|
|
|
function collectRegisterableExportNames(
|
|
mod: Record<string, unknown>,
|
|
declaredExports: Set<string>,
|
|
): Set<string> {
|
|
const exportNames = new Set(declaredExports);
|
|
for (const [exportName, exported] of Object.entries(mod)) {
|
|
if (exportName === "default") continue;
|
|
if (isRegisterablePluginExport(exported)) {
|
|
exportNames.add(exportName);
|
|
}
|
|
}
|
|
return exportNames;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 createReactShimSource(reactModule: object): string {
|
|
const exportNames = Object.keys(reactModule)
|
|
.filter((name) => name !== "default" && /^[A-Za-z_$][\w$]*$/.test(name))
|
|
.sort();
|
|
const namedExports = exportNames
|
|
.map((name) => ` export const ${name} = R.${name};`)
|
|
.join("\n");
|
|
|
|
return `
|
|
const R = globalThis.__paperclipPluginBridge__?.react;
|
|
if (!R) {
|
|
throw new Error("Paperclip plugin React runtime is not initialized.");
|
|
}
|
|
export default R;
|
|
${namedExports}
|
|
`;
|
|
}
|
|
|
|
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 = createReactShimSource(ReactModule);
|
|
break;
|
|
case "react/jsx-runtime":
|
|
// Prefer the host's real jsx runtime: rebuilding jsx/jsxs on top of
|
|
// createElement(type, { children }) drops React's static-children
|
|
// marking, so dev React emits "unique key" warnings for every
|
|
// multi-child element rendered by a plugin component.
|
|
source = `
|
|
const BRIDGE = globalThis.__paperclipPluginBridge__;
|
|
const RUNTIME = BRIDGE?.reactJsxRuntime;
|
|
const R = BRIDGE?.react;
|
|
const withKey = ${applyJsxRuntimeKey.toString()};
|
|
const fallbackJsx = (type, props, key) => R.createElement(type, withKey(props, key));
|
|
export const jsx = RUNTIME?.jsx ?? fallbackJsx;
|
|
export const jsxs = RUNTIME?.jsxs ?? fallbackJsx;
|
|
export const Fragment = RUNTIME?.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 ?? {};
|
|
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, copyTextToClipboard } = 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, copyTextToClipboard, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
|
|
`;
|
|
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);
|
|
}
|
|
}
|
|
|
|
const exportNames = collectRegisterableExportNames(mod, declaredExports);
|
|
for (const exportName of exportNames) {
|
|
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;
|
|
|
|
// Also await imports started by another consumer so this observer gets a
|
|
// completion render even when there is no export registration (e.g. errors).
|
|
const unloaded = contributions.filter((c) => {
|
|
const state = pluginLoadStates.get(buildPluginModuleKey(c));
|
|
return state !== "loaded";
|
|
});
|
|
|
|
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 query = useQuery({
|
|
queryKey: queryKeys.plugins.uiContributions,
|
|
queryFn: () => pluginsApi.listUiContributions(),
|
|
enabled: queryEnabled,
|
|
});
|
|
const { data, isLoading: isQueryLoading, error, refetch } = query;
|
|
// One failed fetch backs every outlet on the page, so an outage must not
|
|
// fan out into red boxes: only a real (non-transient) failure is reported.
|
|
const viewKind = queryViewKind(query);
|
|
const retry = useCallback(() => {
|
|
void refetch();
|
|
}, [refetch]);
|
|
|
|
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 (filters.taskSidePanel && !slot.taskSidePanel) 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, filters.taskSidePanel, slotTypesKey]);
|
|
|
|
// A replacement surface must not disappear while an unrelated plugin loads.
|
|
const contributions = useMemo(() => {
|
|
const pluginIds = new Set(slots.map(slot => slot.pluginId));
|
|
return data?.filter(contribution => pluginIds.has(contribution.pluginId));
|
|
}, [data, slots]);
|
|
usePluginModuleLoader(contributions);
|
|
const modulesLoaded = contributions ? aggregateLoadState(contributions) === "loaded" : true;
|
|
// Plugin chrome never shows red for a transient failure, even one that
|
|
// outlasts the retry policy while the server is reachable: with nothing
|
|
// loaded the outlets stay collapsed, and the next mount or focus refetches.
|
|
const transientFailure = viewKind === "error" && isTransientError(error);
|
|
const reportedError = viewKind === "error" && !transientFailure ? error : null;
|
|
const isLoading = queryEnabled && (isQueryLoading || viewKind === "reconnecting" || transientFailure || !modulesLoaded);
|
|
|
|
return {
|
|
slots,
|
|
isLoading,
|
|
errorMessage: reportedError ? describeError(reportedError).body : null,
|
|
error: reportedError,
|
|
retry,
|
|
};
|
|
}
|
|
|
|
type PluginSlotErrorBoundaryProps = {
|
|
slot: ResolvedPluginSlot;
|
|
fallback?: ReactNode;
|
|
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) {
|
|
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
|
|
</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";
|
|
/** Host-specific props; slot/context cannot be overridden. */
|
|
componentProps?: Record<string, unknown>;
|
|
/** Preserve required host navigation if an optional component is unavailable. */
|
|
fallback?: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* 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",
|
|
componentProps,
|
|
fallback,
|
|
}: PluginSlotMountProps) {
|
|
usePluginRegistrySubscription();
|
|
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 (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)}>
|
|
{slot.pluginDisplayName}: {slot.displayName}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (component.kind === "react") {
|
|
const node = createElement(component.component, { ...componentProps, slot, context });
|
|
return (
|
|
<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>
|
|
</PluginSlotErrorBoundary>
|
|
);
|
|
}
|
|
|
|
if (componentProps && fallback !== undefined) return fallback;
|
|
|
|
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;
|
|
missingBehavior?: "hidden" | "placeholder";
|
|
};
|
|
|
|
/**
|
|
* Renders the plugin slots for a host location. An outlet never shows a
|
|
* fetch error: while the contributions cannot be loaded it renders the last
|
|
* loaded slots, or nothing if none have loaded yet. Failures are reported on
|
|
* the plugin settings pages, where someone can act on them.
|
|
*/
|
|
export function PluginSlotOutlet({
|
|
slotTypes,
|
|
context,
|
|
entityType,
|
|
className,
|
|
itemClassName,
|
|
missingBehavior = "hidden",
|
|
}: PluginSlotOutletProps) {
|
|
const { slots } = usePluginSlots({
|
|
slotTypes,
|
|
entityType,
|
|
companyId: context.companyId,
|
|
});
|
|
|
|
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 _createReactShimSourceForTests = createReactShimSource;
|
|
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;
|
|
export const _collectRegisterableExportNamesForTests = collectRegisterableExportNames;
|