Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master:
  Drop lockfile from watcher change
  Tighten plugin dev file watching
  Fix plugin smoke example typecheck
  Fix plugin dev watcher and migration snapshot
  Clarify plugin authoring and external dev workflow
  Expand kitchen sink plugin demos
  fix: set AGENT_HOME env var for agent processes
  Add kitchen sink plugin example
  Simplify plugin runtime and cleanup lifecycle
  Add plugin framework and settings UI

# Conflicts:
#	packages/db/src/migrations/meta/0029_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta
2026-03-14 13:56:09 -05:00
141 changed files with 47521 additions and 961 deletions

View File

@@ -25,6 +25,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";
@@ -114,6 +117,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 />} />
@@ -153,6 +157,7 @@ function boardRoutes() {
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
@@ -164,7 +169,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() {
@@ -297,9 +302,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 />} />

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

@@ -0,0 +1,423 @@
/**
* @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,
} 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.
*
* 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();
* const toolbarLaunchers = rows.flatMap((row) =>
* row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"),
* );
* ```
*/
listUiContributions: () =>
api.get<PluginUiContribution[]>("/plugins/ui-contributions"),
// ===========================================================================
// 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 used for board/company access checks.
* @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 used for board/company access checks.
* @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

@@ -10,6 +10,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
runId?: string | null;
@@ -32,6 +33,8 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
@@ -118,10 +121,14 @@ type TimelineItem =
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
companyId,
projectId,
highlightCommentId,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
@@ -180,6 +187,22 @@ const TimelineList = memo(function TimelineList({
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{companyId ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
@@ -190,6 +213,24 @@ const TimelineList = memo(function TimelineList({
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
@@ -216,6 +257,8 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
linkedRuns = [],
companyId,
projectId,
onAdd,
issueStatus,
agentMap,
@@ -351,7 +394,13 @@ export function CommentThread({
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3>
<TimelineList timeline={timeline} agentMap={agentMap} highlightCommentId={highlightCommentId} />
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
{liveRunSlot}

View File

@@ -1,7 +1,16 @@
import { Clock3, Settings } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
@@ -13,7 +22,28 @@ 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} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
</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

@@ -21,6 +21,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots";
export function Sidebar() {
const { openNewIssue } = useDialog();
@@ -38,6 +39,11 @@ export function Sidebar() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
}
const pluginContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
};
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
@@ -80,6 +86,13 @@ export function Sidebar() {
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
missingBehavior="placeholder"
/>
</div>
<SidebarSection label="Work">
@@ -97,6 +110,14 @@ export function Sidebar() {
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);

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

@@ -83,4 +83,14 @@ 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: ["plugins", "ui-contributions"] 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,
},
};

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,13 @@ 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 { PluginLauncherProvider } from "./plugins/launchers";
import "@mdxeditor/editor/style.css";
import "./index.css";
initPluginBridge(React, ReactDOM);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js");
@@ -42,9 +48,11 @@ createRoot(document.getElementById("root")!).render(
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<DialogProvider>
<App />
</DialogProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>

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

@@ -26,6 +26,8 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -244,6 +246,7 @@ export function IssueDetail() {
queryFn: () => issuesApi.get(issueId!),
enabled: !!issueId,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!),
@@ -333,6 +336,21 @@ export function IssueDetail() {
companyId: selectedCompanyId,
userId: currentUserId,
});
const { slots: issuePluginDetailSlots } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "issue",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const issuePluginTabItems = useMemo(
() => issuePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}`,
label: slot.displayName,
slot,
})),
[issuePluginDetailSlots],
);
const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
@@ -868,6 +886,47 @@ export function IssueDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<PluginSlotOutlet
slotTypes={["taskDetailView"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="space-y-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
@@ -971,12 +1030,19 @@ export function IssueDetail() {
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
{issuePluginTabItems.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
@@ -1242,6 +1308,21 @@ export function IssueDetail() {
</div>
)}
</TabsContent>
{activePluginTab && (
<TabsContent value={activePluginTab.value}>
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
missingBehavior="placeholder"
/>
</TabsContent>
)}
</Tabs>
{linkedApprovals && linkedApprovals.length > 0 && (

View File

@@ -0,0 +1,509 @@
/**
* @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 } = 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: queryKeys.plugins.uiContributions });
};
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>
);
}

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

@@ -0,0 +1,156 @@
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";
import { NotFoundPage } from "./NotFound";
/**
* 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, pluginRoutePath } = useParams<{
companyPrefix?: string;
pluginId?: string;
pluginRoutePath?: string;
}>();
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
if (!routeCompanyPrefix) return null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((c) => c.issuePrefix.toUpperCase() === requested) ?? null;
}, [companies, routeCompanyPrefix]);
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
const resolvedCompanyId = useMemo(() => {
if (routeCompany) return routeCompany.id;
if (routeCompanyPrefix) return null;
return selectedCompanyId ?? null;
}, [routeCompany, 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,
queryFn: () => pluginsApi.listUiContributions(),
enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath),
});
const pageSlot = useMemo(() => {
if (!contributions) return null;
if (pluginId) {
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,
};
}
if (!pluginRoutePath) return null;
const matches = contributions.flatMap((contribution) => {
const slot = contribution.slots.find((entry) => entry.type === "page" && entry.routePath === pluginRoutePath);
if (!slot) return [];
return [{
...slot,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
}];
});
if (matches.length !== 1) return null;
return matches[0] ?? null;
}, [pluginId, pluginRoutePath, 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) {
if (hasInvalidCompanyPrefix) {
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
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 (!pluginId && pluginRoutePath) {
const duplicateMatches = contributions.filter((contribution) =>
contribution.slots.some((slot) => slot.type === "page" && slot.routePath === pluginRoutePath),
);
if (duplicateMatches.length > 1) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Multiple plugins declare the route <code>{pluginRoutePath}</code>. Use the plugin-id route until the conflict is resolved.
</div>
);
}
}
if (!pageSlot) {
if (pluginRoutePath) {
return <NotFoundPage scope="board" />;
}
// No page slot: redirect to plugin settings where plugin info is always shown
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
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,18 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, 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 +221,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 +236,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 +291,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 +362,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 +376,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") {
@@ -364,12 +406,47 @@ export function ProjectDetail() {
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="project"
context={{
companyId: resolvedCompanyId ?? null,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
projectRef: canonicalProjectRef,
entityId: project.id,
entityType: "project",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar
items={[
{ 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 +479,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,69 @@
/**
* 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 {
usePluginData,
usePluginAction,
useHostContext,
usePluginStream,
usePluginToast,
} 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: {
usePluginData,
usePluginAction,
useHostContext,
usePluginStream,
usePluginToast,
},
};
}

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

@@ -0,0 +1,475 @@
/**
* 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";
import { useToast, type ToastInput } from "@/context/ToastContext";
// ---------------------------------------------------------------------------
// 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;
}
export type PluginToastInput = ToastInput;
export type PluginToastFn = (input: PluginToastInput) => string | null;
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// usePluginToast — concrete implementation
// ---------------------------------------------------------------------------
export function usePluginToast(): PluginToastFn {
const { pushToast } = useToast();
return useCallback(
(input: PluginToastInput) => pushToast(input),
[pushToast],
);
}
// ---------------------------------------------------------------------------
// usePluginStream — concrete implementation
// ---------------------------------------------------------------------------
export interface PluginStreamResult<T = unknown> {
events: T[];
lastEvent: T | null;
connecting: boolean;
connected: boolean;
error: Error | null;
close(): void;
}
export function usePluginStream<T = unknown>(
channel: string,
options?: { companyId?: string },
): PluginStreamResult<T> {
const { pluginId, hostContext } = usePluginBridgeContext();
const effectiveCompanyId = options?.companyId ?? hostContext.companyId ?? undefined;
const [events, setEvents] = useState<T[]>([]);
const [lastEvent, setLastEvent] = useState<T | null>(null);
const [connecting, setConnecting] = useState<boolean>(Boolean(effectiveCompanyId));
const [connected, setConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
const sourceRef = useRef<EventSource | null>(null);
const close = useCallback(() => {
sourceRef.current?.close();
sourceRef.current = null;
setConnecting(false);
setConnected(false);
}, []);
useEffect(() => {
setEvents([]);
setLastEvent(null);
setError(null);
if (!effectiveCompanyId) {
close();
return;
}
const params = new URLSearchParams({ companyId: effectiveCompanyId });
const source = new EventSource(
`/api/plugins/${encodeURIComponent(pluginId)}/bridge/stream/${encodeURIComponent(channel)}?${params.toString()}`,
{ withCredentials: true },
);
sourceRef.current = source;
setConnecting(true);
setConnected(false);
source.onopen = () => {
setConnecting(false);
setConnected(true);
setError(null);
};
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as T;
setEvents((current) => [...current, parsed]);
setLastEvent(parsed);
} catch (nextError) {
setError(nextError instanceof Error ? nextError : new Error(String(nextError)));
}
};
source.addEventListener("close", () => {
source.close();
if (sourceRef.current === source) {
sourceRef.current = null;
}
setConnecting(false);
setConnected(false);
});
source.onerror = () => {
setConnecting(false);
setConnected(false);
setError(new Error(`Failed to connect to plugin stream "${channel}"`));
source.close();
if (sourceRef.current === source) {
sourceRef.current = null;
}
};
return () => {
source.close();
if (sourceRef.current === source) {
sourceRef.current = null;
}
};
}, [channel, close, effectiveCompanyId, pluginId]);
return { events, lastEvent, connecting, connected, error, close };
}

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,
queryFn: () => pluginsApi.listUiContributions(),
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>
);
}

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

@@ -0,0 +1,854 @@
/**
* @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, usePluginStream, usePluginToast } = SDK;
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
`;
break;
}
const blob = new Blob([source], { type: "application/javascript" });
const url = URL.createObjectURL(blob);
shimBlobUrls[specifier] = url;
return url;
}
/**
* Rewrite bare specifier imports in an ESM source string to use blob URLs.
*
* This handles the standard import patterns emitted by esbuild/rollup:
* - `import { ... } from "react";`
* - `import React from "react";`
* - `import * as React from "react";`
* - `import { ... } from "@paperclipai/plugin-sdk/ui";`
*
* Also handles re-exports:
* - `export { ... } from "react";`
*/
function rewriteBareSpecifiers(source: string): string {
// Build a mapping of bare specifiers to blob URLs.
const rewrites: Record<string, string> = {
'"@paperclipai/plugin-sdk/ui"': `"${getShimBlobUrl("sdk-ui")}"`,
"'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`,
'"@paperclipai/plugin-sdk/ui/hooks"': `"${getShimBlobUrl("sdk-ui")}"`,
"'@paperclipai/plugin-sdk/ui/hooks'": `'${getShimBlobUrl("sdk-ui")}'`,
'"react/jsx-runtime"': `"${getShimBlobUrl("react/jsx-runtime")}"`,
"'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`,
'"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`,
"'react-dom/client'": `'${getShimBlobUrl("react-dom/client")}'`,
'"react-dom"': `"${getShimBlobUrl("react-dom")}"`,
"'react-dom'": `'${getShimBlobUrl("react-dom")}'`,
'"react"': `"${getShimBlobUrl("react")}"`,
"'react'": `'${getShimBlobUrl("react")}'`,
};
let result = source;
for (const [from, to] of Object.entries(rewrites)) {
// Only rewrite in import/export from contexts, not in arbitrary strings.
// The regex matches `from "..."` or `from '...'` patterns.
result = result.replaceAll(` from ${from}`, ` from ${to}`);
// Also handle `import "..."` (side-effect imports)
result = result.replaceAll(`import ${from}`, `import ${to}`);
}
return result;
}
/**
* Fetch, rewrite, and import a plugin UI module.
*
* @param url - The URL to the plugin's UI entry module
* @returns The module's exports
*/
async function importPluginModule(url: string): Promise<Record<string, unknown>> {
// Check if the bridge registry is available. If not, fall back to direct
// import (which will fail on bare specifiers but won't crash the loader).
if (!globalThis.__paperclipPluginBridge__) {
console.warn("[plugin-loader] Bridge registry not initialized, falling back to direct import");
return import(/* @vite-ignore */ url);
}
// Fetch the module source text
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch plugin module: ${response.status} ${response.statusText}`);
}
const source = await response.text();
// Rewrite bare specifier imports to blob URLs
const rewritten = rewriteBareSpecifiers(source);
// Create a blob URL from the rewritten source and import it
const blob = new Blob([rewritten], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
try {
const mod = await import(/* @vite-ignore */ blobUrl);
return mod;
} finally {
// Clean up the blob URL after import (the module is already loaded)
URL.revokeObjectURL(blobUrl);
}
}
/**
* Dynamically import a plugin's UI entry module and register all named
* exports that look like React components (functions or classes) into the
* component registry.
*
* This replaces the previous approach where plugin bundles had to
* self-register via `window.paperclipPlugins.registerReactComponent()`.
* Now the host is responsible for importing the module and binding
* exports to the correct `pluginKey:exportName` registry keys.
*
* Plugin modules are loaded with bare-specifier rewriting so that imports
* of `@paperclipai/plugin-sdk/ui`, `react`, and `react-dom` resolve to the
* host-provided implementations via the bridge registry.
*
* Web-component registrations still work: if the module has a named export
* that matches an `exportName` declared in a slot AND that export is a
* string (the custom element tag name), it's registered as a web component.
*/
async function loadPluginModule(contribution: PluginUiContribution): Promise<void> {
const { pluginId, pluginKey, slots, launchers } = contribution;
const moduleKey = buildPluginModuleKey(contribution);
// Already loaded or loading — return early.
const state = pluginLoadStates.get(moduleKey);
if (state === "loaded" || state === "loading") {
// If currently loading, wait for the inflight promise.
const inflight = inflightImports.get(pluginId);
if (inflight) await inflight;
return;
}
// If another import for this plugin ID is currently in progress, wait for it.
const running = inflightImports.get(pluginId);
if (running) {
await running;
const recheckedState = pluginLoadStates.get(moduleKey);
if (recheckedState === "loaded") {
return;
}
}
pluginLoadStates.set(moduleKey, "loading");
const url = buildPluginUiUrl(contribution);
const importPromise = (async () => {
try {
// Dynamic ESM import of the plugin's UI entry module with
// bare-specifier rewriting for host-provided dependencies.
const mod: Record<string, unknown> = await importPluginModule(url);
// Collect the set of export names declared across all UI contributions so
// we only register what the manifest advertises (ignore extra exports).
const declaredExports = new Set<string>();
for (const slot of slots) {
declaredExports.add(slot.exportName);
}
for (const launcher of launchers) {
if (launcher.exportName) {
declaredExports.add(launcher.exportName);
}
if (isLauncherComponentTarget(launcher)) {
declaredExports.add(launcher.action.target);
}
}
for (const exportName of declaredExports) {
const exported = mod[exportName];
if (exported === undefined) {
console.warn(
`Plugin "${pluginKey}" declares slot export "${exportName}" but the module does not export it.`,
);
continue;
}
if (typeof exported === "function") {
// React component (function component or class component).
registerPluginReactComponent(
pluginKey,
exportName,
exported as ComponentType<PluginSlotComponentProps>,
);
} else if (typeof exported === "string") {
// Web component tag name.
registerPluginWebComponent(pluginKey, exportName, exported);
} else {
console.warn(
`Plugin "${pluginKey}" export "${exportName}" is neither a function nor a string tag name — skipping.`,
);
}
}
pluginLoadStates.set(moduleKey, "loaded");
} catch (err) {
pluginLoadStates.set(moduleKey, "error");
console.error(`Failed to load UI module for plugin "${pluginKey}"`, err);
} finally {
inflightImports.delete(pluginId);
}
})();
inflightImports.set(pluginId, importPromise);
await importPromise;
}
function isLauncherComponentTarget(launcher: PluginLauncherDeclaration): boolean {
return launcher.action.type === "openModal"
|| launcher.action.type === "openDrawer"
|| launcher.action.type === "openPopover";
}
/**
* Load UI modules for a set of plugin contributions.
*
* Returns a promise that resolves once all modules have been loaded (or
* failed). Plugins that are already loaded are skipped.
*/
async function ensurePluginModulesLoaded(contributions: PluginUiContribution[]): Promise<void> {
await Promise.all(
contributions.map((c) => loadPluginModule(c)),
);
}
export async function ensurePluginContributionLoaded(
contribution: PluginUiContribution,
): Promise<void> {
await loadPluginModule(contribution);
}
/**
* Returns the aggregate load state across a set of plugin contributions.
* - If any plugin is still loading → "loading"
* - If all are loaded (or no contributions) → "loaded"
* - If all finished but some errored → "loaded" (errors are logged, not fatal)
*/
function aggregateLoadState(contributions: PluginUiContribution[]): "loading" | "loaded" {
for (const c of contributions) {
const state = pluginLoadStates.get(buildPluginModuleKey(c));
if (state === "loading" || state === "idle" || state === undefined) {
return "loading";
}
}
return "loaded";
}
// ---------------------------------------------------------------------------
// React hooks
// ---------------------------------------------------------------------------
/**
* Trigger dynamic loading of plugin UI modules when contributions change.
*
* This hook is intentionally decoupled from usePluginSlots so that callers
* who consume slots via `usePluginSlots()` automatically get module loading
* without extra wiring.
*/
function usePluginModuleLoader(contributions: PluginUiContribution[] | undefined) {
const [, setTick] = useState(0);
useEffect(() => {
if (!contributions || contributions.length === 0) return;
// Filter to contributions that haven't been loaded yet.
const unloaded = contributions.filter((c) => {
const state = pluginLoadStates.get(buildPluginModuleKey(c));
return state !== "loaded" && state !== "loading";
});
if (unloaded.length === 0) return;
let cancelled = false;
void ensurePluginModulesLoaded(unloaded).then(() => {
// Re-render so the slot mount can resolve the newly-registered components.
if (!cancelled) setTick((t) => t + 1);
});
return () => {
cancelled = true;
};
}, [contributions]);
}
/**
* Resolves and sorts slots across all ready plugin contributions.
*
* Filtering rules:
* - `slotTypes` must match one of the caller-requested host slot types.
* - Entity-scoped slot types (`detailTab`, `taskDetailView`, `contextMenuItem`)
* require `entityType` and must include it in `slot.entityTypes`.
*
* Automatically triggers dynamic import of plugin UI modules for any
* newly-discovered contributions. Components render once loading completes.
*/
export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult {
const queryEnabled = filters.enabled ?? true;
const { data, isLoading: isQueryLoading, error } = useQuery({
queryKey: queryKeys.plugins.uiContributions,
queryFn: () => pluginsApi.listUiContributions(),
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;