Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View File

@@ -24,6 +24,9 @@ import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { PluginPage } from "./pages/PluginPage";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
@@ -113,6 +116,7 @@ function boardRoutes() {
<Route path="company/settings" element={<CompanySettings />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />
<Route path="org" element={<OrgChart />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
@@ -162,7 +166,7 @@ function InboxRootRedirect() {
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
@@ -295,9 +299,12 @@ export function App() {
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings" replace />} />
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<InstanceSettings />} />
<Route index element={<Navigate to="heartbeats" replace />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />

View File

@@ -41,6 +41,8 @@ export const api = {
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: "POST", body }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),

469
ui/src/api/plugins.ts Normal file
View File

@@ -0,0 +1,469 @@
/**
* @fileoverview Frontend API client for the Paperclip plugin system.
*
* All functions in `pluginsApi` map 1:1 to REST endpoints on
* `server/src/routes/plugins.ts`. Call sites should consume these functions
* through React Query hooks (`useQuery` / `useMutation`) and reference cache
* keys from `queryKeys.plugins.*`.
*
* @see ui/src/lib/queryKeys.ts for cache key definitions.
* @see server/src/routes/plugins.ts for endpoint implementation details.
*/
import type {
PluginLauncherDeclaration,
PluginLauncherRenderContextSnapshot,
PluginUiSlotDeclaration,
PluginRecord,
PluginConfig,
PluginStatus,
CompanyPluginAvailability,
} from "@paperclipai/shared";
import { api } from "./client";
/**
* Normalized UI contribution record returned by `GET /api/plugins/ui-contributions`.
*
* Only populated for plugins in `ready` state that declare at least one UI slot
* or launcher. The `slots` array is sourced from `manifest.ui.slots`. The
* `launchers` array aggregates both legacy `manifest.launchers` and
* `manifest.ui.launchers`.
*/
export type PluginUiContribution = {
pluginId: string;
pluginKey: string;
displayName: string;
version: string;
updatedAt?: string;
/**
* Relative filename of the UI entry module within the plugin's UI directory.
* The host constructs the full import URL as
* `/_plugins/${pluginId}/ui/${uiEntryFile}`.
*/
uiEntryFile: string;
slots: PluginUiSlotDeclaration[];
launchers: PluginLauncherDeclaration[];
};
/**
* Health check result returned by `GET /api/plugins/:pluginId/health`.
*
* The `healthy` flag summarises whether all checks passed. Individual check
* results are available in `checks` for detailed diagnostics display.
*/
export interface PluginHealthCheckResult {
pluginId: string;
/** The plugin's current lifecycle status at time of check. */
status: string;
/** True if all health checks passed. */
healthy: boolean;
/** Individual diagnostic check results. */
checks: Array<{
name: string;
passed: boolean;
/** Human-readable description of a failure, if any. */
message?: string;
}>;
/** The most recent error message if the plugin is in `error` state. */
lastError?: string;
}
/**
* Worker diagnostics returned as part of the dashboard response.
*/
export interface PluginWorkerDiagnostics {
status: string;
pid: number | null;
uptime: number | null;
consecutiveCrashes: number;
totalCrashes: number;
pendingRequests: number;
lastCrashAt: number | null;
nextRestartAt: number | null;
}
/**
* A recent job run entry returned in the dashboard response.
*/
export interface PluginDashboardJobRun {
id: string;
jobId: string;
jobKey?: string;
trigger: string;
status: string;
durationMs: number | null;
error: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
/**
* A recent webhook delivery entry returned in the dashboard response.
*/
export interface PluginDashboardWebhookDelivery {
id: string;
webhookKey: string;
status: string;
durationMs: number | null;
error: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
/**
* Aggregated health dashboard data returned by `GET /api/plugins/:pluginId/dashboard`.
*
* Contains worker diagnostics, recent job runs, recent webhook deliveries,
* and the current health check result — all in a single response.
*/
export interface PluginDashboardData {
pluginId: string;
/** Worker process diagnostics, or null if no worker is registered. */
worker: PluginWorkerDiagnostics | null;
/** Recent job execution history (newest first, max 10). */
recentJobRuns: PluginDashboardJobRun[];
/** Recent inbound webhook deliveries (newest first, max 10). */
recentWebhookDeliveries: PluginDashboardWebhookDelivery[];
/** Current health check results. */
health: PluginHealthCheckResult;
/** ISO 8601 timestamp when the dashboard data was generated. */
checkedAt: string;
}
export interface AvailablePluginExample {
packageName: string;
pluginKey: string;
displayName: string;
description: string;
localPath: string;
tag: "example";
}
/**
* Plugin management API client.
*
* All methods are thin wrappers around the `api` base client. They return
* promises that resolve to typed JSON responses or throw on HTTP errors.
*
* @example
* ```tsx
* // In a component:
* const { data: plugins } = useQuery({
* queryKey: queryKeys.plugins.all,
* queryFn: () => pluginsApi.list(),
* });
* ```
*/
export const pluginsApi = {
/**
* List all installed plugins, optionally filtered by lifecycle status.
*
* @param status - Optional filter; must be a valid `PluginStatus` value.
* Invalid values are rejected by the server with HTTP 400.
*/
list: (status?: PluginStatus) =>
api.get<PluginRecord[]>(`/plugins${status ? `?status=${status}` : ""}`),
/**
* List bundled example plugins available from the current repo checkout.
*/
listExamples: () =>
api.get<AvailablePluginExample[]>("/plugins/examples"),
/**
* Fetch a single plugin record by its UUID or plugin key.
*
* @param pluginId - The plugin's UUID (from `PluginRecord.id`) or plugin key.
*/
get: (pluginId: string) =>
api.get<PluginRecord>(`/plugins/${pluginId}`),
/**
* Install a plugin from npm or a local path.
*
* On success, the plugin is registered in the database and transitioned to
* `ready` state. The response is the newly created `PluginRecord`.
*
* @param params.packageName - npm package name (e.g. `@paperclip/plugin-linear`)
* or a filesystem path when `isLocalPath` is `true`.
* @param params.version - Target npm version tag/range (optional; defaults to latest).
* @param params.isLocalPath - Set to `true` when `packageName` is a local path.
*/
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
api.post<PluginRecord>("/plugins/install", params),
/**
* Uninstall a plugin.
*
* @param pluginId - UUID of the plugin to remove.
* @param purge - If `true`, permanently delete all plugin data (hard delete).
* Otherwise the plugin is soft-deleted with a 30-day data retention window.
*/
uninstall: (pluginId: string, purge?: boolean) =>
api.delete<{ ok: boolean }>(`/plugins/${pluginId}${purge ? "?purge=true" : ""}`),
/**
* Transition a plugin from `error` state back to `ready`.
* No-ops if the plugin is already enabled.
*
* @param pluginId - UUID of the plugin to enable.
*/
enable: (pluginId: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/enable`, {}),
/**
* Disable a plugin (transition to `error` state with an operator sentinel).
* The plugin's worker is stopped; it will not process events until re-enabled.
*
* @param pluginId - UUID of the plugin to disable.
* @param reason - Optional human-readable reason stored in `lastError`.
*/
disable: (pluginId: string, reason?: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/disable`, reason ? { reason } : {}),
/**
* Run health diagnostics for a plugin.
*
* Only meaningful for plugins in `ready` state. Returns the result of all
* registered health checks. Called on a 30-second polling interval by
* {@link PluginSettings}.
*
* @param pluginId - UUID of the plugin to health-check.
*/
health: (pluginId: string) =>
api.get<PluginHealthCheckResult>(`/plugins/${pluginId}/health`),
/**
* Fetch aggregated health dashboard data for a plugin.
*
* Returns worker diagnostics, recent job runs, recent webhook deliveries,
* and the current health check result in a single request. Used by the
* {@link PluginSettings} page to render the runtime dashboard section.
*
* @param pluginId - UUID of the plugin.
*/
dashboard: (pluginId: string) =>
api.get<PluginDashboardData>(`/plugins/${pluginId}/dashboard`),
/**
* Fetch recent log entries for a plugin.
*
* @param pluginId - UUID of the plugin.
* @param options - Optional filters: limit, level, since.
*/
logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.level) params.set("level", options.level);
if (options?.since) params.set("since", options.since);
const qs = params.toString();
return api.get<Array<{ id: string; pluginId: string; level: string; message: string; meta: Record<string, unknown> | null; createdAt: string }>>(
`/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`,
);
},
/**
* Upgrade a plugin to a newer version.
*
* If the new version declares additional capabilities, the plugin is
* transitioned to `upgrade_pending` state awaiting operator approval.
*
* @param pluginId - UUID of the plugin to upgrade.
* @param version - Target version (optional; defaults to latest published).
*/
upgrade: (pluginId: string, version?: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/upgrade`, version ? { version } : {}),
/**
* Returns normalized UI contribution declarations for ready plugins.
* Used by the slot host runtime and launcher discovery surfaces.
*
* When `companyId` is provided, the server filters out plugins that are
* disabled for that company before returning contributions.
*
* Response shape:
* - `slots`: concrete React mount declarations from `manifest.ui.slots`
* - `launchers`: host-owned entry points from `manifest.ui.launchers` plus
* the legacy top-level `manifest.launchers`
*
* @example
* ```ts
* const rows = await pluginsApi.listUiContributions(companyId);
* const toolbarLaunchers = rows.flatMap((row) =>
* row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"),
* );
* ```
*/
listUiContributions: (companyId?: string) =>
api.get<PluginUiContribution[]>(
`/plugins/ui-contributions${companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""}`,
),
/**
* List plugin availability/settings for a specific company.
*
* @param companyId - UUID of the company.
* @param available - Optional availability filter.
*/
listForCompany: (companyId: string, available?: boolean) =>
api.get<CompanyPluginAvailability[]>(
`/companies/${companyId}/plugins${available === undefined ? "" : `?available=${available}`}`,
),
/**
* Fetch a single company-scoped plugin availability/settings record.
*
* @param companyId - UUID of the company.
* @param pluginId - Plugin UUID or plugin key.
*/
getForCompany: (companyId: string, pluginId: string) =>
api.get<CompanyPluginAvailability>(`/companies/${companyId}/plugins/${pluginId}`),
/**
* Create, update, or clear company-scoped plugin settings.
*
* Company availability is enabled by default. This endpoint stores explicit
* overrides in `plugin_company_settings` so the selected company can be
* disabled without affecting the global plugin installation.
*/
saveForCompany: (
companyId: string,
pluginId: string,
params: {
available: boolean;
settingsJson?: Record<string, unknown>;
lastError?: string | null;
},
) =>
api.put<CompanyPluginAvailability>(`/companies/${companyId}/plugins/${pluginId}`, params),
// ===========================================================================
// Plugin configuration endpoints
// ===========================================================================
/**
* Fetch the current configuration for a plugin.
*
* Returns the `PluginConfig` record if one exists, or `null` if the plugin
* has not yet been configured.
*
* @param pluginId - UUID of the plugin.
*/
getConfig: (pluginId: string) =>
api.get<PluginConfig | null>(`/plugins/${pluginId}/config`),
/**
* Save (create or update) the configuration for a plugin.
*
* The server validates `configJson` against the plugin's `instanceConfigSchema`
* and returns the persisted `PluginConfig` record on success.
*
* @param pluginId - UUID of the plugin.
* @param configJson - Configuration values matching the plugin's `instanceConfigSchema`.
*/
saveConfig: (pluginId: string, configJson: Record<string, unknown>) =>
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { configJson }),
/**
* Call the plugin's `validateConfig` RPC method to test the configuration
* without persisting it.
*
* Returns `{ valid: true }` on success, or `{ valid: false, message: string }`
* when the plugin reports a validation failure.
*
* Only available when the plugin declares a `validateConfig` RPC handler.
*
* @param pluginId - UUID of the plugin.
* @param configJson - Configuration values to validate.
*/
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
// ===========================================================================
// Bridge proxy endpoints — used by the plugin UI bridge runtime
// ===========================================================================
/**
* Proxy a `getData` call from a plugin UI component to its worker backend.
*
* This is the HTTP transport for `usePluginData(key, params)`. The bridge
* runtime calls this method and maps the response into `PluginDataResult<T>`.
*
* On success, the response is `{ data: T }`.
* On failure, the response body is a `PluginBridgeError`-shaped object
* with `code`, `message`, and optional `details`.
*
* @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined data key (e.g. `"sync-health"`)
* @param params - Optional query parameters forwarded to the worker handler
* @param companyId - Optional company scope. When present, the server rejects
* the call with HTTP 403 if the plugin is disabled for that company.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution.
*
* Error responses:
* - `401`/`403` when auth or company access checks fail
* - `404` when the plugin or handler key does not exist
* - `409` when the plugin is not in a callable runtime state
* - `5xx` with a `PluginBridgeError`-shaped body when the worker throws
*
* @see PLUGIN_SPEC.md §13.8 — `getData`
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
bridgeGetData: (
pluginId: string,
key: string,
params?: Record<string, unknown>,
companyId?: string | null,
renderEnvironment?: PluginLauncherRenderContextSnapshot | null,
) =>
api.post<{ data: unknown }>(`/plugins/${pluginId}/data/${encodeURIComponent(key)}`, {
companyId: companyId ?? undefined,
params,
renderEnvironment: renderEnvironment ?? undefined,
}),
/**
* Proxy a `performAction` call from a plugin UI component to its worker backend.
*
* This is the HTTP transport for `usePluginAction(key)`. The bridge runtime
* calls this method when the action function is invoked.
*
* On success, the response is `{ data: T }`.
* On failure, the response body is a `PluginBridgeError`-shaped object
* with `code`, `message`, and optional `details`.
*
* @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined action key (e.g. `"resync"`)
* @param params - Optional parameters forwarded to the worker handler
* @param companyId - Optional company scope. When present, the server rejects
* the call with HTTP 403 if the plugin is disabled for that company.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution.
*
* Error responses:
* - `401`/`403` when auth or company access checks fail
* - `404` when the plugin or handler key does not exist
* - `409` when the plugin is not in a callable runtime state
* - `5xx` with a `PluginBridgeError`-shaped body when the worker throws
*
* @see PLUGIN_SPEC.md §13.9 — `performAction`
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
bridgePerformAction: (
pluginId: string,
key: string,
params?: Record<string, unknown>,
companyId?: string | null,
renderEnvironment?: PluginLauncherRenderContextSnapshot | null,
) =>
api.post<{ data: unknown }>(`/plugins/${pluginId}/actions/${encodeURIComponent(key)}`, {
companyId: companyId ?? undefined,
params,
renderEnvironment: renderEnvironment ?? undefined,
}),
};

View File

@@ -1,4 +1,4 @@
import { Clock3, Settings } from "lucide-react";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
@@ -13,7 +13,8 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings" label="Heartbeats" icon={Clock3} />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
</div>
</nav>
</aside>

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,37 @@ import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
try {
return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
} catch {
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
}
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
@@ -49,6 +80,7 @@ export function Layout() {
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -220,6 +252,21 @@ export function Layout() {
};
}, [isMobile]);
useEffect(() => {
if (!location.pathname.startsWith("/instance/settings/")) return;
const nextPath = normalizeRememberedInstanceSettingsPath(
`${location.pathname}${location.search}${location.hash}`,
);
setInstanceSettingsTarget(nextPath);
try {
window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
} catch {
// Ignore storage failures in restricted environments.
}
}, [location.hash, location.pathname, location.search]);
return (
<div
className={cn(
@@ -235,7 +282,6 @@ export function Layout() {
</a>
<WorktreeBanner />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<button
type="button"
@@ -245,7 +291,6 @@ export function Layout() {
/>
)}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
@@ -270,7 +315,7 @@ export function Layout() {
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@@ -320,7 +365,7 @@ export function Layout() {
</a>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
@@ -346,7 +391,6 @@ export function Layout() {
</div>
)}
{/* Main content */}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(

View File

@@ -25,17 +25,26 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import type { Project } from "@paperclipai/shared";
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
function SortableProjectItem({
activeProjectRef,
companyId,
companyPrefix,
isMobile,
project,
projectSidebarSlots,
setSidebarOpen,
}: {
activeProjectRef: string | null;
companyId: string | null;
companyPrefix: string | null;
isMobile: boolean;
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
setSidebarOpen: (open: boolean) => void;
}) {
const {
@@ -61,31 +70,52 @@ function SortableProjectItem({
{...attributes}
{...listeners}
>
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
<div className="flex flex-col gap-0.5">
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
{projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => (
<PluginSlotMount
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
slot={slot}
context={{
companyId,
companyPrefix,
projectId: project.id,
projectRef: routeRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
))}
</div>
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
</div>
</div>
);
}
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
@@ -99,6 +129,12 @@ export function SidebarProjects() {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { slots: projectSidebarSlots } = usePluginSlots({
slotTypes: ["projectSidebarItem"],
entityType: "project",
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
@@ -178,8 +214,11 @@ export function SidebarProjects() {
<SortableProjectItem
key={project.id}
activeProjectRef={activeProjectRef}
companyId={selectedCompanyId}
companyPrefix={selectedCompany?.issuePrefix ?? null}
isMobile={isMobile}
project={project}
projectSidebarSlots={projectSidebarSlots}
setSidebarOpen={setSidebarOpen}
/>
))}

View File

@@ -75,4 +75,20 @@ export const queryKeys = {
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const,
plugins: {
all: ["plugins"] as const,
examples: ["plugins", "examples"] as const,
detail: (pluginId: string) => ["plugins", pluginId] as const,
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
uiContributions: (companyId?: string | null) =>
["plugins", "ui-contributions", companyId ?? "global"] as const,
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
company: (companyId: string) => ["plugins", "company", companyId] as const,
companyList: (companyId: string, available?: boolean) =>
["plugins", "company", companyId, "list", available ?? "all"] as const,
companyDetail: (companyId: string, pluginId: string) =>
["plugins", "company", companyId, pluginId] as const,
},
};

View File

@@ -1,4 +1,6 @@
import * as React from "react";
import { StrictMode } from "react";
import * as ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "@/lib/router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -12,9 +14,12 @@ import { DialogProvider } from "./context/DialogContext";
import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { initPluginBridge } from "./plugins/bridge-init";
import "@mdxeditor/editor/style.css";
import "./index.css";
initPluginBridge(React, ReactDOM);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");

View File

@@ -24,6 +24,7 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
@@ -276,6 +277,13 @@ export function Dashboard() {
</ChartCard>
</div>
<PluginSlotOutlet
slotTypes={["dashboardWidget"]}
context={{ companyId: selectedCompanyId }}
className="grid gap-4 md:grid-cols-2"
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
/>
<div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */}
{recentActivity.length > 0 && (

View File

@@ -0,0 +1,512 @@
/**
* @fileoverview Plugin Manager page — admin UI for discovering,
* installing, enabling/disabling, and uninstalling plugins.
*
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
*/
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { PluginRecord } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value
.split(/\r?\n/)
.map((entry) => entry.trim())
.find(Boolean);
return line ?? null;
}
function getPluginErrorSummary(plugin: PluginRecord): string {
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
}
/**
* PluginManager page component.
*
* Provides a management UI for the Paperclip plugin system:
* - Lists all installed plugins with their status, version, and category badges.
* - Allows installing new plugins by npm package name.
* - Provides per-plugin actions: enable, disable, navigate to settings.
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
*
* Data flow:
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
* - Mutations (install / uninstall / enable / disable) invalidate
* `queryKeys.plugins.all` so the list refreshes automatically.
*
* @see PluginSettings — linked from the Settings icon on each plugin row.
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
*/
export function PluginManager() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [installPackage, setInstallPackage] = useState("");
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/heartbeats" },
{ label: "Plugins" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: plugins, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
const examplesQuery = useQuery({
queryKey: queryKeys.plugins.examples,
queryFn: () => pluginsApi.listExamples(),
});
const invalidatePluginQueries = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
queryClient.invalidateQueries({ queryKey: ["plugins", "ui-contributions"] });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.companyList(selectedCompanyId) });
}
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
pluginsApi.install(params),
onSuccess: () => {
invalidatePluginQueries();
setInstallDialogOpen(false);
setInstallPackage("");
pushToast({ title: "Plugin installed successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
},
});
const uninstallMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
},
});
const enableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin enabled", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
},
});
const disableMutation = useMutation({
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
onSuccess: () => {
invalidatePluginQueries();
pushToast({ title: "Plugin disabled", tone: "info" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
},
});
const installedPlugins = plugins ?? [];
const examples = examplesQuery.data ?? [];
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
const examplePackageNames = new Set(examples.map((example) => example.packageName));
const errorSummaryByPluginId = useMemo(
() =>
new Map(
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
),
[installedPlugins]
);
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Puzzle className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Plugin Manager</h1>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install Plugin</DialogTitle>
<DialogDescription>
Enter the npm package name of the plugin you wish to install.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="packageName">npm Package Name</Label>
<Input
id="packageName"
placeholder="@paperclipai/plugin-example"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => installMutation.mutate({ packageName: installPackage })}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">Plugins are alpha.</p>
<p className="text-muted-foreground">
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
</p>
</div>
</div>
</div>
<section className="space-y-3">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Examples</Badge>
</div>
{examplesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
) : examplesQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
) : examples.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled example plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{examples.map((example) => {
const installedPlugin = installedByPackageName.get(example.packageName);
const installPending =
installMutation.isPending &&
installMutation.variables?.isLocalPath &&
installMutation.variables.packageName === example.localPath;
return (
<li key={example.packageName}>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{example.displayName}</span>
<Badge variant="outline">Example</Badge>
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
>
{installedPlugin.status}
</Badge>
) : (
<Badge variant="secondary">Not installed</Badge>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{installedPlugin ? (
<>
{installedPlugin.status !== "ready" && (
<Button
variant="outline"
size="sm"
disabled={enableMutation.isPending}
onClick={() => enableMutation.mutate(installedPlugin.id)}
>
Enable
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/instance/settings/plugins/${installedPlugin.id}`}>
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
</Link>
</Button>
</>
) : (
<Button
size="sm"
disabled={installPending || installMutation.isPending}
onClick={() =>
installMutation.mutate({
packageName: example.localPath,
isLocalPath: true,
})
}
>
{installPending ? "Installing..." : "Install Example"}
</Button>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Installed Plugins</h2>
</div>
{!installedPlugins.length ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install a plugin to extend functionality.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{installedPlugins.map((plugin) => (
<li key={plugin.id}>
<div className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/instance/settings/plugins/${plugin.id}`}
className="font-medium hover:underline truncate block"
title={plugin.manifestJson.displayName ?? plugin.packageName}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</Link>
{examplePackageNames.has(plugin.packageName) && (
<Badge variant="outline">Example</Badge>
)}
</div>
<div>
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
</p>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
{plugin.manifestJson.description || "No description provided."}
</p>
{plugin.status === "error" && (
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Plugin error</span>
</div>
<p
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
title={plugin.lastError ?? undefined}
>
{errorSummaryByPluginId.get(plugin.id)}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
onClick={() => setErrorDetailsPlugin(plugin)}
>
View full error
</Button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 self-center">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<Badge
variant={
plugin.status === "ready"
? "default"
: plugin.status === "error"
? "destructive"
: "secondary"
}
className={cn(
"shrink-0",
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
)}
>
{plugin.status}
</Badge>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={plugin.status === "ready" ? "Disable" : "Enable"}
onClick={() => {
if (plugin.status === "ready") {
disableMutation.mutate(plugin.id);
} else {
enableMutation.mutate(plugin.id);
}
}}
disabled={enableMutation.isPending || disableMutation.isPending}
>
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
</Button>
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Uninstall"
onClick={() => {
setUninstallPluginId(plugin.id);
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
}}
disabled={uninstallMutation.isPending}
>
<Trash className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
<Link to={`/instance/settings/plugins/${plugin.id}`}>
<Settings className="h-4 w-4" />
Configure
</Link>
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</section>
<Dialog
open={uninstallPluginId !== null}
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall Plugin</DialogTitle>
<DialogDescription>
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={uninstallMutation.isPending}
onClick={() => {
if (uninstallPluginId) {
uninstallMutation.mutate(uninstallPluginId, {
onSettled: () => setUninstallPluginId(null),
});
}
}}
>
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={errorDetailsPlugin !== null}
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Error Details</DialogTitle>
<DialogDescription>
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="space-y-1 text-sm">
<p className="font-medium text-red-700 dark:text-red-300">
What errored
</p>
<p className="text-red-700/90 dark:text-red-200/90 break-words">
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
</p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Full error output</p>
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
{errorDetailsPlugin?.lastError ?? "No stored error message."}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

113
ui/src/pages/PluginPage.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo } from "react";
import { Link, Navigate, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { PluginSlotMount } from "@/plugins/slots";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
/**
* Company-context plugin page. Renders a plugin's `page` slot at
* `/:companyPrefix/plugins/:pluginId` when the plugin declares a page slot
* and is enabled for that company.
*
* @see doc/plugins/PLUGIN_SPEC.md §19.2 — Company-Context Routes
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
*/
export function PluginPage() {
const { companyPrefix: routeCompanyPrefix, pluginId } = useParams<{
companyPrefix?: string;
pluginId: string;
}>();
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const resolvedCompanyId = useMemo(() => {
if (!routeCompanyPrefix) return selectedCompanyId ?? null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((c) => c.issuePrefix.toUpperCase() === requested)?.id ?? selectedCompanyId ?? null;
}, [companies, routeCompanyPrefix, selectedCompanyId]);
const companyPrefix = useMemo(
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
[companies, resolvedCompanyId],
);
const { data: contributions } = useQuery({
queryKey: queryKeys.plugins.uiContributions(resolvedCompanyId ?? undefined),
queryFn: () => pluginsApi.listUiContributions(resolvedCompanyId ?? undefined),
enabled: !!resolvedCompanyId && !!pluginId,
});
const pageSlot = useMemo(() => {
if (!pluginId || !contributions) return null;
const contribution = contributions.find((c) => c.pluginId === pluginId);
if (!contribution) return null;
const slot = contribution.slots.find((s) => s.type === "page");
if (!slot) return null;
return {
...slot,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
};
}, [pluginId, contributions]);
const context = useMemo(
() => ({
companyId: resolvedCompanyId ?? null,
companyPrefix,
}),
[resolvedCompanyId, companyPrefix],
);
useEffect(() => {
if (pageSlot) {
setBreadcrumbs([
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: pageSlot.pluginDisplayName },
]);
}
}, [pageSlot, companyPrefix, setBreadcrumbs]);
if (!resolvedCompanyId) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
</div>
);
}
if (!contributions) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
if (!pageSlot) {
// No page slot: redirect to plugin settings where plugin info is always shown
const settingsPath = `/instance/settings/plugins/${pluginId}`;
return <Navigate to={settingsPath} replace />;
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
</Button>
</div>
<PluginSlotMount
slot={pageSlot}
context={context}
className="min-h-[200px]"
missingBehavior="placeholder"
/>
</div>
);
}

View File

@@ -0,0 +1,836 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { Link, Navigate, useParams } from "@/lib/router";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { PageTabBar } from "@/components/PageTabBar";
import {
JsonSchemaForm,
validateJsonSchemaForm,
getDefaultValues,
type JsonSchemaNode,
} from "@/components/JsonSchemaForm";
/**
* PluginSettings page component.
*
* Detailed settings and diagnostics page for a single installed plugin.
* Navigated to from {@link PluginManager} via the Settings gear icon.
*
* Displays:
* - Plugin identity: display name, id, version, description, categories.
* - Manifest-declared capabilities (what data and features the plugin can access).
* - Health check results (only for `ready` plugins; polled every 30 seconds).
* - Runtime dashboard: worker status/uptime, recent job runs, webhook deliveries.
* - Auto-generated config form from `instanceConfigSchema` (when no custom settings page).
* - Plugin-contributed settings UI via `<PluginSlotOutlet type="settingsPage" />`.
*
* Data flow:
* - `GET /api/plugins/:pluginId` — plugin record (refreshes on mount).
* - `GET /api/plugins/:pluginId/health` — health diagnostics (polling).
* Only fetched when `plugin.status === "ready"`.
* - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling).
* - `GET /api/plugins/:pluginId/config` — current config values.
* - `POST /api/plugins/:pluginId/config` — save config values.
* - `POST /api/plugins/:pluginId/config/test` — test configuration.
*
* URL params:
* - `companyPrefix` — the company slug (for breadcrumb links).
* - `pluginId` — UUID of the plugin to display.
*
* @see PluginManager — parent list page.
* @see doc/plugins/PLUGIN_SPEC.md §13 — Plugin Health Checks.
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
*/
export function PluginSettings() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
const { data: plugin, isLoading: pluginLoading } = useQuery({
queryKey: queryKeys.plugins.detail(pluginId!),
queryFn: () => pluginsApi.get(pluginId!),
enabled: !!pluginId,
});
const { data: healthData, isLoading: healthLoading } = useQuery({
queryKey: queryKeys.plugins.health(pluginId!),
queryFn: () => pluginsApi.health(pluginId!),
enabled: !!pluginId && plugin?.status === "ready",
refetchInterval: 30000,
});
const { data: dashboardData } = useQuery({
queryKey: queryKeys.plugins.dashboard(pluginId!),
queryFn: () => pluginsApi.dashboard(pluginId!),
enabled: !!pluginId,
refetchInterval: 30000,
});
const { data: recentLogs } = useQuery({
queryKey: queryKeys.plugins.logs(pluginId!),
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
enabled: !!pluginId && plugin?.status === "ready",
refetchInterval: 30000,
});
// Fetch existing config for the plugin
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
const { data: configData, isLoading: configLoading } = useQuery({
queryKey: queryKeys.plugins.config(pluginId!),
queryFn: () => pluginsApi.getConfig(pluginId!),
enabled: !!pluginId && !!hasConfigSchema,
});
const { slots } = usePluginSlots({
slotTypes: ["settingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
// Filter slots to only show settings pages for this specific plugin
const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId);
// If the plugin has a custom settingsPage slot, prefer that over auto-generated form
const hasCustomSettingsPage = pluginSlots.length > 0;
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/heartbeats" },
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
]);
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
useEffect(() => {
setActiveTab("configuration");
}, [pluginId]);
if (pluginLoading) {
return <div className="p-4 text-sm text-muted-foreground">Loading plugin details...</div>;
}
if (!plugin) {
return <Navigate to="/instance/settings/plugins" replace />;
}
const displayStatus = plugin.status;
const statusVariant =
plugin.status === "ready"
? "default"
: plugin.status === "error"
? "destructive"
: "secondary";
const pluginDescription = plugin.manifestJson.description || "No description provided.";
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
return (
<div className="space-y-6 max-w-5xl">
<div className="flex items-center gap-4">
<Link to="/instance/settings/plugins">
<Button variant="outline" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center gap-2">
<Puzzle className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">{plugin.manifestJson.displayName ?? plugin.packageName}</h1>
<Badge variant={statusVariant} className="ml-2">
{displayStatus}
</Badge>
<Badge variant="outline" className="ml-1">
v{plugin.manifestJson.version ?? plugin.version}
</Badge>
</div>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "configuration" | "status")} className="space-y-6">
<PageTabBar
align="start"
items={[
{ value: "configuration", label: "Configuration" },
{ value: "status", label: "Status" },
]}
value={activeTab}
onValueChange={(value) => setActiveTab(value as "configuration" | "status")}
/>
<TabsContent value="configuration" className="space-y-6">
<div className="space-y-8">
<section className="space-y-5">
<h2 className="text-base font-semibold">About</h2>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.8fr)]">
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<p className="text-sm leading-6 text-foreground/90">{pluginDescription}</p>
</div>
<div className="space-y-4 text-sm">
<div className="space-y-1.5">
<h3 className="font-medium text-muted-foreground">Author</h3>
<p className="text-foreground">{plugin.manifestJson.author}</p>
</div>
<div className="space-y-2">
<h3 className="font-medium text-muted-foreground">Categories</h3>
<div className="flex flex-wrap gap-2">
{plugin.categories.length > 0 ? (
plugin.categories.map((category) => (
<Badge key={category} variant="outline" className="capitalize">
{category}
</Badge>
))
) : (
<span className="text-foreground">None</span>
)}
</div>
</div>
</div>
</div>
</section>
<Separator />
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">Settings</h2>
</div>
{hasCustomSettingsPage ? (
<div className="space-y-3">
{pluginSlots.map((slot) => (
<PluginSlotMount
key={`${slot.pluginKey}:${slot.id}`}
slot={slot}
context={{
companyId: selectedCompanyId,
companyPrefix: companyPrefix ?? null,
}}
missingBehavior="placeholder"
/>
))}
</div>
) : hasConfigSchema ? (
<PluginConfigForm
pluginId={pluginId!}
schema={configSchema!}
initialValues={configData?.configJson}
isLoading={configLoading}
pluginStatus={plugin.status}
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
/>
) : (
<p className="text-sm text-muted-foreground">
This plugin does not require any settings.
</p>
)}
</section>
</div>
</TabsContent>
<TabsContent value="status" className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-1.5">
<Cpu className="h-4 w-4" />
Runtime Dashboard
</CardTitle>
<CardDescription>
Worker process, scheduled jobs, and webhook deliveries
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{dashboardData ? (
<>
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
Worker Process
</h3>
{dashboardData.worker ? (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<Badge variant={dashboardData.worker.status === "running" ? "default" : "secondary"}>
{dashboardData.worker.status}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">PID</span>
<span className="font-mono text-xs">{dashboardData.worker.pid ?? "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Uptime</span>
<span className="text-xs">{formatUptime(dashboardData.worker.uptime)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pending RPCs</span>
<span className="text-xs">{dashboardData.worker.pendingRequests}</span>
</div>
{dashboardData.worker.totalCrashes > 0 && (
<>
<div className="flex justify-between col-span-2">
<span className="text-muted-foreground flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-amber-500" />
Crashes
</span>
<span className="text-xs">
{dashboardData.worker.consecutiveCrashes} consecutive / {dashboardData.worker.totalCrashes} total
</span>
</div>
{dashboardData.worker.lastCrashAt && (
<div className="flex justify-between col-span-2">
<span className="text-muted-foreground">Last Crash</span>
<span className="text-xs">{formatTimestamp(dashboardData.worker.lastCrashAt)}</span>
</div>
)}
</>
)}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No worker process registered.</p>
)}
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
Recent Job Runs
</h3>
{dashboardData.recentJobRuns.length > 0 ? (
<div className="space-y-2">
{dashboardData.recentJobRuns.map((run) => (
<div
key={run.id}
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
<JobStatusDot status={run.status} />
<span className="truncate font-mono text-xs" title={run.jobKey ?? run.jobId}>
{run.jobKey ?? run.jobId.slice(0, 8)}
</span>
<Badge variant="outline" className="px-1 py-0 text-[10px]">
{run.trigger}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{run.durationMs != null ? <span>{formatDuration(run.durationMs)}</span> : null}
<span title={run.createdAt}>{formatRelativeTime(run.createdAt)}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No job runs recorded yet.</p>
)}
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
Recent Webhook Deliveries
</h3>
{dashboardData.recentWebhookDeliveries.length > 0 ? (
<div className="space-y-2">
{dashboardData.recentWebhookDeliveries.map((delivery) => (
<div
key={delivery.id}
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
<DeliveryStatusDot status={delivery.status} />
<span className="truncate font-mono text-xs" title={delivery.webhookKey}>
{delivery.webhookKey}
</span>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{delivery.durationMs != null ? <span>{formatDuration(delivery.durationMs)}</span> : null}
<span title={delivery.createdAt}>{formatRelativeTime(delivery.createdAt)}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No webhook deliveries recorded yet.</p>
)}
</div>
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Last checked: {new Date(dashboardData.checkedAt).toLocaleTimeString()}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Runtime diagnostics are unavailable right now.
</p>
)}
</CardContent>
</Card>
{recentLogs && recentLogs.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-1.5">
<ActivitySquare className="h-4 w-4" />
Recent Logs
</CardTitle>
<CardDescription>Last {recentLogs.length} log entries</CardDescription>
</CardHeader>
<CardContent>
<div className="max-h-64 space-y-1 overflow-y-auto font-mono text-xs">
{recentLogs.map((entry) => (
<div
key={entry.id}
className={`flex gap-2 py-0.5 ${
entry.level === "error"
? "text-destructive"
: entry.level === "warn"
? "text-yellow-600 dark:text-yellow-400"
: entry.level === "debug"
? "text-muted-foreground/60"
: "text-muted-foreground"
}`}
>
<span className="shrink-0 text-muted-foreground/50">{new Date(entry.createdAt).toLocaleTimeString()}</span>
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">{entry.level}</Badge>
<span className="truncate" title={entry.message}>{entry.message}</span>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-1.5">
<ActivitySquare className="h-4 w-4" />
Health Status
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<p className="text-sm text-muted-foreground">Checking health...</p>
) : healthData ? (
<div className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Overall</span>
<Badge variant={healthData.healthy ? "default" : "destructive"}>
{healthData.status}
</Badge>
</div>
{healthData.checks.length > 0 ? (
<div className="space-y-2 border-t border-border/50 pt-2">
{healthData.checks.map((check, i) => (
<div key={i} className="flex items-start justify-between gap-2">
<span className="truncate text-muted-foreground" title={check.name}>
{check.name}
</span>
{check.passed ? (
<CheckCircle className="h-4 w-4 shrink-0 text-green-500" />
) : (
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
)}
</div>
))}
</div>
) : null}
{healthData.lastError ? (
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
{healthData.lastError}
</div>
) : null}
</div>
) : (
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Lifecycle</span>
<Badge variant={statusVariant}>{displayStatus}</Badge>
</div>
<p>Health checks run once the plugin is ready.</p>
{plugin.lastError ? (
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
{plugin.lastError}
</div>
) : null}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex justify-between gap-3">
<span>Plugin ID</span>
<span className="font-mono text-xs text-right">{plugin.id}</span>
</div>
<div className="flex justify-between gap-3">
<span>Plugin Key</span>
<span className="font-mono text-xs text-right">{plugin.pluginKey}</span>
</div>
<div className="flex justify-between gap-3">
<span>NPM Package</span>
<span className="max-w-[170px] truncate text-right text-xs" title={plugin.packageName}>
{plugin.packageName}
</span>
</div>
<div className="flex justify-between gap-3">
<span>Version</span>
<span className="text-right text-foreground">v{plugin.manifestJson.version ?? plugin.version}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-1.5">
<ShieldAlert className="h-4 w-4" />
Permissions
</CardTitle>
</CardHeader>
<CardContent>
{pluginCapabilities.length > 0 ? (
<ul className="space-y-2 text-sm text-muted-foreground">
{pluginCapabilities.map((cap) => (
<li key={cap} className="rounded-md bg-muted/40 px-2.5 py-2 font-mono text-xs text-foreground/85">
{cap}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground italic">No special permissions requested.</p>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ---------------------------------------------------------------------------
// PluginConfigForm — auto-generated form for instanceConfigSchema
// ---------------------------------------------------------------------------
interface PluginConfigFormProps {
pluginId: string;
schema: JsonSchemaNode;
initialValues?: Record<string, unknown>;
isLoading?: boolean;
/** Current plugin lifecycle status — "Test Configuration" only available when `ready`. */
pluginStatus?: string;
/** Whether the plugin worker implements `validateConfig`. */
supportsConfigTest?: boolean;
}
/**
* Inner component that manages form state, validation, save, and "Test Configuration"
* for the auto-generated plugin config form.
*
* Separated from PluginSettings to isolate re-render scope — only the form
* re-renders on field changes, not the entire page.
*/
function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
const queryClient = useQueryClient();
// Form values: start with saved values, fall back to schema defaults
const [values, setValues] = useState<Record<string, unknown>>(() => ({
...getDefaultValues(schema),
...(initialValues ?? {}),
}));
// Sync when saved config loads asynchronously — only on first load so we
// don't overwrite in-progress user edits if the query refetches (e.g. on
// window focus).
const hasHydratedRef = useRef(false);
useEffect(() => {
if (initialValues && !hasHydratedRef.current) {
hasHydratedRef.current = true;
setValues({
...getDefaultValues(schema),
...initialValues,
});
}
}, [initialValues, schema]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [testResult, setTestResult] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Dirty tracking: compare against initial values
const isDirty = JSON.stringify(values) !== JSON.stringify({
...getDefaultValues(schema),
...(initialValues ?? {}),
});
// Save mutation
const saveMutation = useMutation({
mutationFn: (configJson: Record<string, unknown>) =>
pluginsApi.saveConfig(pluginId, configJson),
onSuccess: () => {
setSaveMessage({ type: "success", text: "Configuration saved." });
setTestResult(null);
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
// Clear success message after 3s
setTimeout(() => setSaveMessage(null), 3000);
},
onError: (err: Error) => {
setSaveMessage({ type: "error", text: err.message || "Failed to save configuration." });
},
});
// Test configuration mutation
const testMutation = useMutation({
mutationFn: (configJson: Record<string, unknown>) =>
pluginsApi.testConfig(pluginId, configJson),
onSuccess: (result) => {
if (result.valid) {
setTestResult({ type: "success", text: "Configuration test passed." });
} else {
setTestResult({ type: "error", text: result.message || "Configuration test failed." });
}
},
onError: (err: Error) => {
setTestResult({ type: "error", text: err.message || "Configuration test failed." });
},
});
const handleChange = useCallback((newValues: Record<string, unknown>) => {
setValues(newValues);
// Clear field-level errors as the user types
setErrors({});
setSaveMessage(null);
}, []);
const handleSave = useCallback(() => {
// Validate before saving
const validationErrors = validateJsonSchemaForm(schema, values);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
saveMutation.mutate(values);
}, [schema, values, saveMutation]);
const handleTestConnection = useCallback(() => {
// Validate before testing
const validationErrors = validateJsonSchemaForm(schema, values);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setTestResult(null);
testMutation.mutate(values);
}, [schema, values, testMutation]);
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading configuration...
</div>
);
}
return (
<div className="space-y-4">
<JsonSchemaForm
schema={schema}
values={values}
onChange={handleChange}
errors={errors}
disabled={saveMutation.isPending}
/>
{/* Status messages */}
{saveMessage && (
<div
className={`text-sm p-2 rounded border ${
saveMessage.type === "success"
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
: "text-destructive bg-destructive/10 border-destructive/20"
}`}
>
{saveMessage.text}
</div>
)}
{testResult && (
<div
className={`text-sm p-2 rounded border ${
testResult.type === "success"
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
: "text-destructive bg-destructive/10 border-destructive/20"
}`}
>
{testResult.text}
</div>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !isDirty}
size="sm"
>
{saveMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
"Save Configuration"
)}
</Button>
{pluginStatus === "ready" && supportsConfigTest && (
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testMutation.isPending}
size="sm"
>
{testMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Testing...
</>
) : (
"Test Configuration"
)}
</Button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Dashboard helper components and formatting utilities
// ---------------------------------------------------------------------------
/**
* Format an uptime value (in milliseconds) to a human-readable string.
*/
function formatUptime(uptimeMs: number | null): string {
if (uptimeMs == null) return "—";
const totalSeconds = Math.floor(uptimeMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ${minutes % 60}m`;
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
/**
* Format a duration in milliseconds to a compact display string.
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
/**
* Format an ISO timestamp to a relative time string (e.g., "2m ago").
*/
function formatRelativeTime(isoString: string): string {
const now = Date.now();
const then = new Date(isoString).getTime();
const diffMs = now - then;
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* Format a unix timestamp (ms since epoch) to a locale string.
*/
function formatTimestamp(epochMs: number): string {
return new Date(epochMs).toLocaleString();
}
/**
* Status indicator dot for job run statuses.
*/
function JobStatusDot({ status }: { status: string }) {
const colorClass =
status === "success" || status === "succeeded"
? "bg-green-500"
: status === "failed"
? "bg-red-500"
: status === "running"
? "bg-blue-500 animate-pulse"
: status === "cancelled"
? "bg-gray-400"
: "bg-amber-500"; // queued, pending
return (
<span
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
title={status}
/>
);
}
/**
* Status indicator dot for webhook delivery statuses.
*/
function DeliveryStatusDot({ status }: { status: string }) {
const colorClass =
status === "processed" || status === "success"
? "bg-green-500"
: status === "failed"
? "bg-red-500"
: status === "received"
? "bg-blue-500"
: "bg-amber-500"; // pending
return (
<span
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
title={status}
/>
);
}

View File

@@ -19,10 +19,17 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
/* ── Top-level tab types ── */
type ProjectTab = "overview" | "list" | "configuration";
type ProjectBaseTab = "overview" | "list" | "configuration";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
return typeof value === "string" && value.startsWith("plugin:");
}
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
const segments = pathname.split("/").filter(Boolean);
@@ -213,8 +220,12 @@ export function ProjectDetail() {
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
const activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
const pluginTabFromSearch = useMemo(() => {
const tab = new URLSearchParams(location.search).get("tab");
return isProjectPluginTab(tab) ? tab : null;
}, [location.search]);
const activeTab = activeRouteTab ?? pluginTabFromSearch;
const { data: project, isLoading, error } = useQuery({
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
@@ -224,6 +235,24 @@ export function ProjectDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const projectLookupRef = project?.id ?? routeProjectRef;
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
const {
slots: pluginDetailSlots,
isLoading: pluginDetailSlotsLoading,
} = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "project",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const pluginTabItems = useMemo(
() => pluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab,
label: slot.displayName,
slot,
})),
[pluginDetailSlots],
);
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
@@ -261,6 +290,10 @@ export function ProjectDetail() {
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
if (isProjectPluginTab(activeTab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
return;
}
if (activeTab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return;
@@ -328,6 +361,10 @@ export function ProjectDetail() {
}
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
// Redirect bare /projects/:id to /projects/:id/issues
if (routeProjectRef && activeTab === null) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
@@ -338,6 +375,10 @@ export function ProjectDetail() {
if (!project) return null;
const handleTabChange = (tab: ProjectTab) => {
if (isProjectPluginTab(tab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
return;
}
if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "configuration") {
@@ -370,6 +411,10 @@ export function ProjectDetail() {
{ value: "overview", label: "Overview" },
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" },
...pluginTabItems.map((item) => ({
value: item.value,
label: item.label,
})),
]}
align="start"
value={activeTab ?? "list"}
@@ -402,6 +447,21 @@ export function ProjectDetail() {
/>
</div>
)}
{activePluginTab && (
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: resolvedCompanyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
/**
* Plugin bridge initialization.
*
* Registers the host's React instances and bridge hook implementations
* on a global object so that the plugin module loader can inject them
* into plugin UI bundles at load time.
*
* Call `initPluginBridge()` once during app startup (in `main.tsx`), before
* any plugin UI modules are loaded.
*
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
*/
import type { ReactNode } from "react";
import {
usePluginData,
usePluginAction,
useHostContext,
} from "./bridge.js";
// ---------------------------------------------------------------------------
// Global bridge registry
// ---------------------------------------------------------------------------
/**
* The global bridge registry shape.
*
* This is placed on `globalThis.__paperclipPluginBridge__` and consumed by
* the plugin module loader to provide implementations for external imports.
*/
export interface PluginBridgeRegistry {
react: unknown;
reactDom: unknown;
sdkUi: Record<string, unknown>;
}
declare global {
// eslint-disable-next-line no-var
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
}
/**
* Initialize the plugin bridge global registry.
*
* Registers the host's React, ReactDOM, and SDK UI bridge implementations
* on `globalThis.__paperclipPluginBridge__` so the plugin module loader
* can provide them to plugin bundles.
*
* @param react - The host's React module
* @param reactDom - The host's ReactDOM module
*/
export function initPluginBridge(
react: typeof import("react"),
reactDom: typeof import("react-dom"),
): void {
globalThis.__paperclipPluginBridge__ = {
react,
reactDom,
sdkUi: {
// Bridge hooks
usePluginData,
usePluginAction,
useHostContext,
// Placeholder shared UI components — plugins that use these will get
// functional stubs. Full implementations matching the host's design
// system can be added later.
MetricCard: createStubComponent("MetricCard"),
StatusBadge: createStubComponent("StatusBadge"),
DataTable: createStubComponent("DataTable"),
TimeseriesChart: createStubComponent("TimeseriesChart"),
MarkdownBlock: createStubComponent("MarkdownBlock"),
KeyValueList: createStubComponent("KeyValueList"),
ActionBar: createStubComponent("ActionBar"),
LogView: createStubComponent("LogView"),
JsonTree: createStubComponent("JsonTree"),
Spinner: createStubComponent("Spinner"),
ErrorBoundary: createPassthroughComponent("ErrorBoundary"),
},
};
}
// ---------------------------------------------------------------------------
// Stub component helpers
// ---------------------------------------------------------------------------
function createStubComponent(name: string): unknown {
const fn = (props: Record<string, unknown>) => {
// Import React from the registry to avoid import issues
const React = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
if (!React) return null;
return React.createElement("div", {
"data-plugin-component": name,
style: {
padding: "8px",
border: "1px dashed #666",
borderRadius: "4px",
fontSize: "12px",
color: "#888",
},
}, `[${name}]`);
};
Object.defineProperty(fn, "name", { value: name });
return fn;
}
function createPassthroughComponent(name: string): unknown {
const fn = (props: { children?: ReactNode }) => {
const ReactLib = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
if (!ReactLib) return null;
return ReactLib.createElement(ReactLib.Fragment, null, props.children);
};
Object.defineProperty(fn, "name", { value: name });
return fn;
}

361
ui/src/plugins/bridge.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* Plugin UI bridge runtime — concrete implementations of the bridge hooks.
*
* Plugin UI bundles import `usePluginData`, `usePluginAction`, and
* `useHostContext` from `@paperclipai/plugin-sdk/ui`. Those are type-only
* declarations in the SDK package. The host provides the real implementations
* by injecting this bridge runtime into the plugin's module scope.
*
* The bridge runtime communicates with plugin workers via HTTP REST endpoints:
* - `POST /api/plugins/:pluginId/data/:key` — proxies `getData` RPC
* - `POST /api/plugins/:pluginId/actions/:key` — proxies `performAction` RPC
*
* ## How it works
*
* 1. Before loading a plugin's UI module, the host creates a scoped bridge via
* `createPluginBridge(pluginId)`.
* 2. The bridge's hook implementations are registered in a global bridge
* registry keyed by `pluginId`.
* 3. The "ambient" hooks (`usePluginData`, `usePluginAction`, `useHostContext`)
* look up the current plugin context from a React context provider and
* delegate to the appropriate bridge instance.
*
* @see PLUGIN_SPEC.md §13.8 — `getData`
* @see PLUGIN_SPEC.md §13.9 — `performAction`
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
import { createContext, useCallback, useContext, useRef, useState, useEffect } from "react";
import type {
PluginBridgeErrorCode,
PluginLauncherBounds,
PluginLauncherRenderContextSnapshot,
PluginLauncherRenderEnvironment,
} from "@paperclipai/shared";
import { pluginsApi } from "@/api/plugins";
import { ApiError } from "@/api/client";
// ---------------------------------------------------------------------------
// Bridge error type (mirrors the SDK's PluginBridgeError)
// ---------------------------------------------------------------------------
/**
* Structured error from the bridge, matching the SDK's `PluginBridgeError`.
*/
export interface PluginBridgeError {
code: PluginBridgeErrorCode;
message: string;
details?: unknown;
}
// ---------------------------------------------------------------------------
// Bridge data result type (mirrors the SDK's PluginDataResult)
// ---------------------------------------------------------------------------
export interface PluginDataResult<T = unknown> {
data: T | null;
loading: boolean;
error: PluginBridgeError | null;
refresh(): void;
}
// ---------------------------------------------------------------------------
// Host context type (mirrors the SDK's PluginHostContext)
// ---------------------------------------------------------------------------
export interface PluginHostContext {
companyId: string | null;
companyPrefix: string | null;
projectId: string | null;
entityId: string | null;
entityType: string | null;
parentEntityId?: string | null;
userId: string | null;
renderEnvironment?: PluginRenderEnvironmentContext | null;
}
export interface PluginModalBoundsRequest {
bounds: PluginLauncherBounds;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
}
export interface PluginRenderCloseEvent {
reason:
| "escapeKey"
| "backdrop"
| "hostNavigation"
| "programmatic"
| "submit"
| "unknown";
nativeEvent?: unknown;
}
export type PluginRenderCloseHandler = (
event: PluginRenderCloseEvent,
) => void | Promise<void>;
export interface PluginRenderCloseLifecycle {
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
onClose?(handler: PluginRenderCloseHandler): () => void;
}
export interface PluginRenderEnvironmentContext {
environment: PluginLauncherRenderEnvironment | null;
launcherId: string | null;
bounds: PluginLauncherBounds | null;
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
closeLifecycle?: PluginRenderCloseLifecycle | null;
}
// ---------------------------------------------------------------------------
// Bridge context — React context for plugin identity and host scope
// ---------------------------------------------------------------------------
export type PluginBridgeContextValue = {
pluginId: string;
hostContext: PluginHostContext;
};
/**
* React context that carries the active plugin identity and host scope.
*
* The slot/launcher mount wraps plugin components in a Provider so that
* bridge hooks (`usePluginData`, `usePluginAction`, `useHostContext`) can
* resolve the current plugin without ambient mutable globals.
*
* Because plugin bundles share the host's React instance (via the bridge
* registry on `globalThis.__paperclipPluginBridge__`), context propagation
* works correctly across the host/plugin boundary.
*/
export const PluginBridgeContext =
createContext<PluginBridgeContextValue | null>(null);
function usePluginBridgeContext(): PluginBridgeContextValue {
const ctx = useContext(PluginBridgeContext);
if (!ctx) {
throw new Error(
"Plugin bridge hook called outside of a <PluginBridgeContext.Provider>. " +
"Ensure the plugin component is rendered within a PluginBridgeScope.",
);
}
return ctx;
}
// ---------------------------------------------------------------------------
// Error extraction helpers
// ---------------------------------------------------------------------------
/**
* Attempt to extract a structured PluginBridgeError from an API error.
*
* The bridge proxy endpoints return error bodies shaped as
* `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`.
* This helper extracts that structure from the ApiError thrown by the client.
*/
function extractBridgeError(err: unknown): PluginBridgeError {
if (err instanceof ApiError && err.body && typeof err.body === "object") {
const body = err.body as Record<string, unknown>;
if (typeof body.code === "string" && typeof body.message === "string") {
return {
code: body.code as PluginBridgeErrorCode,
message: body.message,
details: body.details,
};
}
// Fallback: the server returned a plain { error: string } body
if (typeof body.error === "string") {
return {
code: "UNKNOWN",
message: body.error,
};
}
}
return {
code: "UNKNOWN",
message: err instanceof Error ? err.message : String(err),
};
}
// ---------------------------------------------------------------------------
// usePluginData — concrete implementation
// ---------------------------------------------------------------------------
/**
* Stable serialization of params for use as a dependency key.
* Returns a string that changes only when the params object content changes.
*/
function serializeParams(params?: Record<string, unknown>): string {
if (!params) return "";
try {
return JSON.stringify(params, Object.keys(params).sort());
} catch {
return "";
}
}
function serializeRenderEnvironment(
renderEnvironment?: PluginRenderEnvironmentContext | null,
): PluginLauncherRenderContextSnapshot | null {
if (!renderEnvironment) return null;
return {
environment: renderEnvironment.environment,
launcherId: renderEnvironment.launcherId,
bounds: renderEnvironment.bounds,
};
}
function serializeRenderEnvironmentSnapshot(
snapshot: PluginLauncherRenderContextSnapshot | null,
): string {
return snapshot ? JSON.stringify(snapshot) : "";
}
/**
* Concrete implementation of `usePluginData<T>(key, params)`.
*
* Makes an HTTP POST to `/api/plugins/:pluginId/data/:key` and returns
* a reactive `PluginDataResult<T>` matching the SDK type contract.
*
* Re-fetches automatically when `key` or `params` change. Provides a
* `refresh()` function for manual re-fetch.
*/
export function usePluginData<T = unknown>(
key: string,
params?: Record<string, unknown>,
): PluginDataResult<T> {
const { pluginId, hostContext } = usePluginBridgeContext();
const companyId = hostContext.companyId;
const renderEnvironmentSnapshot = serializeRenderEnvironment(hostContext.renderEnvironment);
const renderEnvironmentKey = serializeRenderEnvironmentSnapshot(renderEnvironmentSnapshot);
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<PluginBridgeError | null>(null);
const [refreshCounter, setRefreshCounter] = useState(0);
// Stable serialization for params change detection
const paramsKey = serializeParams(params);
useEffect(() => {
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryCount = 0;
const maxRetryCount = 2;
const retryableCodes: PluginBridgeErrorCode[] = ["WORKER_UNAVAILABLE", "TIMEOUT"];
setLoading(true);
const request = () => {
pluginsApi
.bridgeGetData(
pluginId,
key,
params,
companyId,
renderEnvironmentSnapshot,
)
.then((response) => {
if (!cancelled) {
setData(response.data as T);
setError(null);
setLoading(false);
}
})
.catch((err: unknown) => {
if (cancelled) return;
const bridgeError = extractBridgeError(err);
if (retryableCodes.includes(bridgeError.code) && retryCount < maxRetryCount) {
retryCount += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
if (!cancelled) request();
}, 150 * retryCount);
return;
}
setError(bridgeError);
setData(null);
setLoading(false);
});
};
request();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId, key, paramsKey, refreshCounter, companyId, renderEnvironmentKey]);
const refresh = useCallback(() => {
setRefreshCounter((c) => c + 1);
}, []);
return { data, loading, error, refresh };
}
// ---------------------------------------------------------------------------
// usePluginAction — concrete implementation
// ---------------------------------------------------------------------------
/**
* Action function type matching the SDK's `PluginActionFn`.
*/
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;
/**
* Concrete implementation of `usePluginAction(key)`.
*
* Returns a stable async function that, when called, sends a POST to
* `/api/plugins/:pluginId/actions/:key` and returns the worker result.
*
* On failure, the function throws a `PluginBridgeError`.
*/
export function usePluginAction(key: string): PluginActionFn {
const bridgeContext = usePluginBridgeContext();
const contextRef = useRef(bridgeContext);
contextRef.current = bridgeContext;
return useCallback(
async (params?: Record<string, unknown>): Promise<unknown> => {
const { pluginId, hostContext } = contextRef.current;
const companyId = hostContext.companyId;
const renderEnvironment = serializeRenderEnvironment(hostContext.renderEnvironment);
try {
const response = await pluginsApi.bridgePerformAction(
pluginId,
key,
params,
companyId,
renderEnvironment,
);
return response.data;
} catch (err) {
throw extractBridgeError(err);
}
},
[key],
);
}
// ---------------------------------------------------------------------------
// useHostContext — concrete implementation
// ---------------------------------------------------------------------------
/**
* Concrete implementation of `useHostContext()`.
*
* Returns the current host context (company, project, entity, user)
* from the enclosing `PluginBridgeContext.Provider`.
*/
export function useHostContext(): PluginHostContext {
const { hostContext } = usePluginBridgeContext();
return hostContext;
}

View File

@@ -0,0 +1,829 @@
import {
Component,
createContext,
createElement,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type CSSProperties,
type ErrorInfo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { PLUGIN_LAUNCHER_BOUNDS } from "@paperclipai/shared";
import type {
PluginLauncherBounds,
PluginLauncherDeclaration,
PluginLauncherPlacementZone,
PluginUiSlotEntityType,
} from "@paperclipai/shared";
import { pluginsApi, type PluginUiContribution } from "@/api/plugins";
import { authApi } from "@/api/auth";
import { Button } from "@/components/ui/button";
import { useNavigate, useLocation } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import {
PluginBridgeContext,
type PluginHostContext,
type PluginModalBoundsRequest,
type PluginRenderCloseEvent,
type PluginRenderCloseHandler,
type PluginRenderEnvironmentContext,
} from "./bridge";
import {
ensurePluginContributionLoaded,
resolveRegisteredPluginComponent,
type RegisteredPluginComponent,
} from "./slots";
export type PluginLauncherContext = {
companyId?: string | null;
companyPrefix?: string | null;
projectId?: string | null;
projectRef?: string | null;
entityId?: string | null;
entityType?: PluginUiSlotEntityType | null;
};
export type ResolvedPluginLauncher = PluginLauncherDeclaration & {
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
pluginVersion: string;
uiEntryFile: string;
};
type UsePluginLaunchersFilters = {
placementZones: PluginLauncherPlacementZone[];
entityType?: PluginUiSlotEntityType | null;
companyId?: string | null;
enabled?: boolean;
};
type UsePluginLaunchersResult = {
launchers: ResolvedPluginLauncher[];
contributionsByPluginId: Map<string, PluginUiContribution>;
isLoading: boolean;
errorMessage: string | null;
};
type PluginLauncherRuntimeContextValue = {
/**
* Open a launcher using already-discovered contribution metadata.
*
* The runtime accepts the normalized `PluginUiContribution` so callers can
* reuse the `/api/plugins/ui-contributions` payload they already fetched
* instead of issuing another request for each launcher activation.
*/
activateLauncher(
launcher: ResolvedPluginLauncher,
hostContext: PluginLauncherContext,
contribution: PluginUiContribution,
sourceEl?: HTMLElement | null,
): Promise<void>;
};
type LauncherInstance = {
key: string;
launcher: ResolvedPluginLauncher;
hostContext: PluginLauncherContext;
contribution: PluginUiContribution;
component: RegisteredPluginComponent | null;
sourceElement: HTMLElement | null;
sourceRect: DOMRect | null;
bounds: PluginLauncherBounds | null;
beforeCloseHandlers: Set<PluginRenderCloseHandler>;
closeHandlers: Set<PluginRenderCloseHandler>;
};
const entityScopedZones = new Set<PluginLauncherPlacementZone>([
"detailTab",
"taskDetailView",
"contextMenuItem",
"commentAnnotation",
"commentContextMenuItem",
"projectSidebarItem",
]);
const focusableElementSelector = [
"button:not([disabled])",
"[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
const launcherOverlayBaseZIndex = 1000;
const supportedLauncherBounds = new Set<PluginLauncherBounds>(
PLUGIN_LAUNCHER_BOUNDS,
);
const PluginLauncherRuntimeContext = createContext<PluginLauncherRuntimeContextValue | null>(null);
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return "Unknown error";
}
function buildLauncherHostContext(
context: PluginLauncherContext,
renderEnvironment: PluginRenderEnvironmentContext | null,
userId: string | null,
): PluginHostContext {
return {
companyId: context.companyId ?? null,
companyPrefix: context.companyPrefix ?? null,
projectId: context.projectId ?? (context.entityType === "project" ? context.entityId ?? null : null),
entityId: context.entityId ?? null,
entityType: context.entityType ?? null,
userId,
renderEnvironment,
};
}
function focusFirstElement(container: HTMLElement | null): void {
if (!container) return;
const firstFocusable = container.querySelector<HTMLElement>(focusableElementSelector);
if (firstFocusable) {
firstFocusable.focus();
return;
}
container.focus();
}
function trapFocus(container: HTMLElement, event: KeyboardEvent): void {
if (event.key !== "Tab") return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(focusableElementSelector),
).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1);
if (focusable.length === 0) {
event.preventDefault();
container.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
function launcherTriggerClassName(placementZone: PluginLauncherPlacementZone): string {
switch (placementZone) {
case "projectSidebarItem":
return "justify-start h-auto px-3 py-1 text-[12px] font-normal text-muted-foreground hover:text-foreground";
case "contextMenuItem":
case "commentContextMenuItem":
return "justify-start h-7 w-full px-2 text-xs font-normal";
case "sidebar":
case "sidebarPanel":
return "justify-start h-8 w-full";
default:
return "h-8";
}
}
function launcherShellBoundsStyle(bounds: PluginLauncherBounds | null): CSSProperties {
switch (bounds) {
case "compact":
return { width: "min(28rem, calc(100vw - 2rem))" };
case "wide":
return { width: "min(64rem, calc(100vw - 2rem))" };
case "full":
return { width: "calc(100vw - 2rem)", height: "calc(100vh - 2rem)" };
case "inline":
return { width: "min(24rem, calc(100vw - 2rem))" };
case "default":
default:
return { width: "min(40rem, calc(100vw - 2rem))" };
}
}
function launcherPopoverStyle(instance: LauncherInstance): CSSProperties {
const rect = instance.sourceRect;
const baseWidth = launcherShellBoundsStyle(instance.bounds).width ?? "min(24rem, calc(100vw - 2rem))";
if (!rect) {
return {
width: baseWidth,
maxHeight: "min(70vh, 36rem)",
top: "4rem",
left: "50%",
transform: "translateX(-50%)",
};
}
const top = Math.min(rect.bottom + 8, window.innerHeight - 32);
const left = Math.min(
Math.max(rect.left, 16),
Math.max(16, window.innerWidth - 360),
);
return {
width: baseWidth,
maxHeight: "min(70vh, 36rem)",
top,
left,
};
}
function isPluginLauncherBounds(value: unknown): value is PluginLauncherBounds {
return typeof value === "string" && supportedLauncherBounds.has(value as PluginLauncherBounds);
}
/**
* Discover launchers for the requested host placement zones from the normalized
* `/api/plugins/ui-contributions` response.
*
* This is the shared discovery path for toolbar, sidebar, detail-view, and
* context-menu launchers. The hook applies host-side entity filtering and
* returns both the sorted launcher list and a contribution map so activation
* can stay on cached metadata.
*/
export function usePluginLaunchers(
filters: UsePluginLaunchersFilters,
): UsePluginLaunchersResult {
const queryEnabled = filters.enabled ?? true;
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.plugins.uiContributions(filters.companyId),
queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined),
enabled: queryEnabled,
});
const placementZonesKey = useMemo(
() => [...filters.placementZones].sort().join("|"),
[filters.placementZones],
);
const contributionsByPluginId = useMemo(() => {
const byPluginId = new Map<string, PluginUiContribution>();
for (const contribution of data ?? []) {
byPluginId.set(contribution.pluginId, contribution);
}
return byPluginId;
}, [data]);
const launchers = useMemo(() => {
const placementZones = new Set(
placementZonesKey.split("|").filter(Boolean) as PluginLauncherPlacementZone[],
);
const rows: ResolvedPluginLauncher[] = [];
for (const contribution of data ?? []) {
for (const launcher of contribution.launchers) {
if (!placementZones.has(launcher.placementZone)) continue;
if (entityScopedZones.has(launcher.placementZone)) {
if (!filters.entityType) continue;
if (!launcher.entityTypes?.includes(filters.entityType)) continue;
}
rows.push({
...launcher,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
uiEntryFile: contribution.uiEntryFile,
});
}
}
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, placementZonesKey]);
return {
launchers,
contributionsByPluginId,
isLoading: queryEnabled && isLoading,
errorMessage: error ? getErrorMessage(error) : null,
};
}
async function resolveLauncherComponent(
contribution: PluginUiContribution,
launcher: ResolvedPluginLauncher,
): Promise<RegisteredPluginComponent | null> {
const exportName = launcher.action.target;
const existing = resolveRegisteredPluginComponent(launcher.pluginKey, exportName);
if (existing) return existing;
await ensurePluginContributionLoaded(contribution);
return resolveRegisteredPluginComponent(launcher.pluginKey, exportName);
}
/**
* Scope bridge calls to the currently rendered launcher host context.
*
* Hooks such as `useHostContext()`, `usePluginData()`, and `usePluginAction()`
* consume this ambient context so the bridge can forward company/entity scope
* and render-environment metadata to the plugin worker.
*/
function PluginLauncherBridgeScope({
pluginId,
hostContext,
children,
}: {
pluginId: string;
hostContext: PluginHostContext;
children: ReactNode;
}) {
const value = useMemo(() => ({ pluginId, hostContext }), [pluginId, hostContext]);
return (
<PluginBridgeContext.Provider value={value}>
{children}
</PluginBridgeContext.Provider>
);
}
type LauncherErrorBoundaryProps = {
launcher: ResolvedPluginLauncher;
children: ReactNode;
};
type LauncherErrorBoundaryState = {
hasError: boolean;
};
class LauncherErrorBoundary extends Component<LauncherErrorBoundaryProps, LauncherErrorBoundaryState> {
override state: LauncherErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): LauncherErrorBoundaryState {
return { hasError: true };
}
override componentDidCatch(error: unknown, info: ErrorInfo): void {
console.error("Plugin launcher render failed", {
pluginKey: this.props.launcher.pluginKey,
launcherId: this.props.launcher.id,
error,
info: info.componentStack,
});
}
override render() {
if (this.state.hasError) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{this.props.launcher.pluginDisplayName}: failed to render
</div>
);
}
return this.props.children;
}
}
function LauncherRenderContent({
instance,
renderEnvironment,
}: {
instance: LauncherInstance;
renderEnvironment: PluginRenderEnvironmentContext;
}) {
const component = instance.component;
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const userId = session?.user?.id ?? session?.session?.userId ?? null;
const hostContext = useMemo(
() => buildLauncherHostContext(instance.hostContext, renderEnvironment, userId),
[instance.hostContext, renderEnvironment, userId],
);
if (!component) {
if (renderEnvironment.environment === "iframe") {
return (
<iframe
src={`/_plugins/${encodeURIComponent(instance.launcher.pluginId)}/ui/${instance.launcher.action.target}`}
title={`${instance.launcher.pluginDisplayName} ${instance.launcher.displayName}`}
className="h-full min-h-[24rem] w-full rounded-md border border-border bg-background"
/>
);
}
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{instance.launcher.pluginDisplayName}: could not resolve launcher target "{instance.launcher.action.target}".
</div>
);
}
if (component.kind === "web-component") {
return createElement(component.tagName, {
className: "block w-full",
pluginLauncher: instance.launcher,
pluginContext: hostContext,
});
}
const node = createElement(component.component as never, {
launcher: instance.launcher,
context: hostContext,
} as never);
return (
<LauncherErrorBoundary launcher={instance.launcher}>
<PluginLauncherBridgeScope pluginId={instance.launcher.pluginId} hostContext={hostContext}>
{node}
</PluginLauncherBridgeScope>
</LauncherErrorBoundary>
);
}
function LauncherModalShell({
instance,
stackIndex,
isTopmost,
requestBounds,
closeLauncher,
}: {
instance: LauncherInstance;
stackIndex: number;
isTopmost: boolean;
requestBounds: (key: string, request: PluginModalBoundsRequest) => Promise<void>;
closeLauncher: (key: string, event: PluginRenderCloseEvent) => Promise<void>;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!isTopmost) return;
const frame = requestAnimationFrame(() => {
focusFirstElement(contentRef.current);
});
return () => cancelAnimationFrame(frame);
}, [isTopmost]);
useEffect(() => {
if (!isTopmost) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (!contentRef.current) return;
if (event.key === "Escape") {
event.preventDefault();
void closeLauncher(instance.key, { reason: "escapeKey", nativeEvent: event });
return;
}
trapFocus(contentRef.current, event);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closeLauncher, instance.key, isTopmost]);
const renderEnvironment = useMemo<PluginRenderEnvironmentContext>(() => ({
environment: instance.launcher.render?.environment ?? "hostOverlay",
launcherId: instance.launcher.id,
bounds: instance.bounds,
requestModalBounds: (request) => requestBounds(instance.key, request),
closeLifecycle: {
onBeforeClose: (handler) => {
instance.beforeCloseHandlers.add(handler);
return () => instance.beforeCloseHandlers.delete(handler);
},
onClose: (handler) => {
instance.closeHandlers.add(handler);
return () => instance.closeHandlers.delete(handler);
},
},
}), [instance, requestBounds]);
const baseZ = launcherOverlayBaseZIndex + stackIndex * 20;
// Keep each launcher in a deterministic z-index band so every stacked modal,
// drawer, or popover retains its own backdrop/panel pairing.
const shellType = instance.launcher.action.type;
const containerStyle = shellType === "openPopover"
? launcherPopoverStyle(instance)
: launcherShellBoundsStyle(instance.bounds);
const panelClassName = shellType === "openDrawer"
? "fixed right-0 top-0 h-full max-w-[min(44rem,100vw)] overflow-hidden border-l border-border bg-background shadow-2xl"
: shellType === "openPopover"
? "fixed overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
: "fixed left-1/2 top-1/2 max-h-[calc(100vh-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl border border-border bg-background shadow-2xl";
return (
<>
<div
className="fixed inset-0 bg-black/45"
style={{ zIndex: baseZ }}
aria-hidden="true"
onMouseDown={(event) => {
if (!isTopmost) return;
if (event.target !== event.currentTarget) return;
void closeLauncher(instance.key, { reason: "backdrop", nativeEvent: event });
}}
/>
<div
ref={contentRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={panelClassName}
style={{
zIndex: baseZ + 1,
...(shellType === "openDrawer"
? { width: containerStyle.width ?? "min(44rem, 100vw)" }
: containerStyle),
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<div className="min-w-0">
<h2 id={titleId} className="truncate text-sm font-semibold">
{instance.launcher.displayName}
</h2>
<p className="truncate text-xs text-muted-foreground">
{instance.launcher.pluginDisplayName}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void closeLauncher(instance.key, { reason: "programmatic" })}
>
Close
</Button>
</div>
<div
className={cn(
"overflow-auto p-4",
shellType === "openDrawer" ? "h-[calc(100%-3.5rem)]" : "max-h-[calc(100vh-7rem)]",
)}
>
<LauncherRenderContent instance={instance} renderEnvironment={renderEnvironment} />
</div>
</div>
</>
);
}
export function PluginLauncherProvider({ children }: { children: ReactNode }) {
const [stack, setStack] = useState<LauncherInstance[]>([]);
const stackRef = useRef(stack);
stackRef.current = stack;
const location = useLocation();
const navigate = useNavigate();
const closeLauncher = useCallback(
async (key: string, event: PluginRenderCloseEvent) => {
const instance = stackRef.current.find((entry) => entry.key === key);
if (!instance) return;
for (const handler of [...instance.beforeCloseHandlers]) {
await handler(event);
}
setStack((current) => current.filter((entry) => entry.key !== key));
queueMicrotask(() => {
for (const handler of [...instance.closeHandlers]) {
void handler(event);
}
if (instance.sourceElement && document.contains(instance.sourceElement)) {
instance.sourceElement.focus();
}
});
},
[],
);
useEffect(() => {
if (stack.length === 0) return;
void Promise.all(
stack.map((entry) => closeLauncher(entry.key, { reason: "hostNavigation" })),
);
// Only react to navigation changes, not stack churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.key]);
const requestBounds = useCallback(
async (key: string, request: PluginModalBoundsRequest) => {
// Bounds changes are host-validated. Unsupported presets are ignored so
// plugin UI cannot push the shell into an undefined layout state.
if (!isPluginLauncherBounds(request.bounds)) {
return;
}
setStack((current) =>
current.map((entry) =>
entry.key === key
? { ...entry, bounds: request.bounds }
: entry,
),
);
},
[],
);
const activateLauncher = useCallback(
async (
launcher: ResolvedPluginLauncher,
hostContext: PluginLauncherContext,
contribution: PluginUiContribution,
sourceEl?: HTMLElement | null,
) => {
switch (launcher.action.type) {
case "navigate":
navigate(launcher.action.target);
return;
case "deepLink":
if (/^https?:\/\//.test(launcher.action.target)) {
window.open(launcher.action.target, "_blank", "noopener,noreferrer");
} else {
navigate(launcher.action.target);
}
return;
case "performAction":
await pluginsApi.bridgePerformAction(
launcher.pluginId,
launcher.action.target,
launcher.action.params,
hostContext.companyId ?? null,
);
return;
case "openModal":
case "openDrawer":
case "openPopover": {
const component = await resolveLauncherComponent(contribution, launcher);
const sourceRect = sourceEl?.getBoundingClientRect() ?? null;
const nextEntry: LauncherInstance = {
key: `${launcher.pluginId}:${launcher.id}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
launcher,
hostContext,
contribution,
component,
sourceElement: sourceEl ?? null,
sourceRect,
bounds: launcher.render?.bounds ?? "default",
beforeCloseHandlers: new Set(),
closeHandlers: new Set(),
};
setStack((current) => [...current, nextEntry]);
return;
}
}
},
[navigate],
);
const value = useMemo<PluginLauncherRuntimeContextValue>(
() => ({ activateLauncher }),
[activateLauncher],
);
return (
<PluginLauncherRuntimeContext.Provider value={value}>
{children}
{stack.map((instance, index) => (
<LauncherModalShell
key={instance.key}
instance={instance}
stackIndex={index}
isTopmost={index === stack.length - 1}
requestBounds={requestBounds}
closeLauncher={closeLauncher}
/>
))}
</PluginLauncherRuntimeContext.Provider>
);
}
export function usePluginLauncherRuntime(): PluginLauncherRuntimeContextValue {
const value = useContext(PluginLauncherRuntimeContext);
if (!value) {
throw new Error("usePluginLauncherRuntime must be used within PluginLauncherProvider");
}
return value;
}
function DefaultLauncherTrigger({
launcher,
placementZone,
onClick,
}: {
launcher: ResolvedPluginLauncher;
placementZone: PluginLauncherPlacementZone;
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
}) {
return (
<Button
type="button"
variant={placementZone === "toolbarButton" ? "outline" : "ghost"}
size="sm"
className={launcherTriggerClassName(placementZone)}
onClick={onClick}
>
{launcher.displayName}
</Button>
);
}
type PluginLauncherOutletProps = {
placementZones: PluginLauncherPlacementZone[];
context: PluginLauncherContext;
entityType?: PluginUiSlotEntityType | null;
className?: string;
itemClassName?: string;
errorClassName?: string;
};
export function PluginLauncherOutlet({
placementZones,
context,
entityType,
className,
itemClassName,
errorClassName,
}: PluginLauncherOutletProps) {
const { activateLauncher } = usePluginLauncherRuntime();
const { launchers, contributionsByPluginId, errorMessage } = usePluginLaunchers({
placementZones,
entityType,
companyId: context.companyId,
enabled: !!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 launchers unavailable: {errorMessage}
</div>
);
}
if (launchers.length === 0) return null;
return (
<div className={className}>
{launchers.map((launcher) => (
<div key={`${launcher.pluginKey}:${launcher.id}`} className={itemClassName}>
<DefaultLauncherTrigger
launcher={launcher}
placementZone={launcher.placementZone}
onClick={(event) => {
const contribution = contributionsByPluginId.get(launcher.pluginId);
if (!contribution) return;
void activateLauncher(launcher, context, contribution, event.currentTarget);
}}
/>
</div>
))}
</div>
);
}
type PluginLauncherButtonProps = {
launcher: ResolvedPluginLauncher;
context: PluginLauncherContext;
contribution: PluginUiContribution;
className?: string;
onActivated?: () => void;
};
export function PluginLauncherButton({
launcher,
context,
contribution,
className,
onActivated,
}: PluginLauncherButtonProps) {
const { activateLauncher } = usePluginLauncherRuntime();
return (
<div className={className}>
<DefaultLauncherTrigger
launcher={launcher}
placementZone={launcher.placementZone}
onClick={(event) => {
event.preventDefault();
onActivated?.();
void activateLauncher(launcher, context, contribution, event.currentTarget);
}}
/>
</div>
);
}

862
ui/src/plugins/slots.tsx Normal file
View File

@@ -0,0 +1,862 @@
/**
* @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;
};
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 {
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem";
}
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 ?? {};
const { usePluginData, usePluginAction, useHostContext,
MetricCard, StatusBadge, DataTable, TimeseriesChart,
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary } = SDK;
export { usePluginData, usePluginAction, useHostContext,
MetricCard, StatusBadge, DataTable, TimeseriesChart,
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary };
`;
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")}'`,
'"@paperclipai/plugin-sdk/ui/components"': `"${getShimBlobUrl("sdk-ui")}"`,
"'@paperclipai/plugin-sdk/ui/components'": `'${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({
queryKey: queryKeys.plugins.uiContributions(filters.companyId),
queryFn: () => pluginsApi.listUiContributions(filters.companyId ?? undefined),
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;