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; 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; }; type LauncherInstance = { key: string; launcher: ResolvedPluginLauncher; hostContext: PluginLauncherContext; contribution: PluginUiContribution; component: RegisteredPluginComponent | null; sourceElement: HTMLElement | null; sourceRect: DOMRect | null; bounds: PluginLauncherBounds | null; beforeCloseHandlers: Set; closeHandlers: Set; }; const entityScopedZones = new Set([ "detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem", "toolbarButton", ]); 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( PLUGIN_LAUNCHER_BOUNDS, ); const PluginLauncherRuntimeContext = createContext(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(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(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"; case "toolbarButton": case "globalToolbarButton": return "h-8"; 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, queryFn: () => pluginsApi.listUiContributions(), enabled: queryEnabled, }); const placementZonesKey = useMemo( () => [...filters.placementZones].sort().join("|"), [filters.placementZones], ); const contributionsByPluginId = useMemo(() => { const byPluginId = new Map(); 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 { 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 ( {children} ); } type LauncherErrorBoundaryProps = { launcher: ResolvedPluginLauncher; children: ReactNode; }; type LauncherErrorBoundaryState = { hasError: boolean; }; class LauncherErrorBoundary extends Component { 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 (
{this.props.launcher.pluginDisplayName}: failed to render
); } 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 (