Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
@@ -25,6 +25,9 @@ import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
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";
|
||||
@@ -115,6 +118,7 @@ function boardRoutes() {
|
||||
<Route path="skills/*" element={<CompanySkills />} />
|
||||
<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 />} />
|
||||
|
||||
@@ -41,6 +41,8 @@ export const api = {
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
request<T>(path, { method: "POST", body }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclipai/shared";
|
||||
import type {
|
||||
Approval,
|
||||
DocumentRevision,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
UpsertIssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
@@ -53,6 +62,14 @@ export const issuesApi = {
|
||||
...(interrupt === undefined ? {} : { interrupt }),
|
||||
},
|
||||
),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
listDocumentRevisions: (id: string, key: string) =>
|
||||
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||
deleteDocument: (id: string, key: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||
uploadAttachment: (
|
||||
companyId: string,
|
||||
|
||||
423
ui/src/api/plugins.ts
Normal file
423
ui/src/api/plugins.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
@@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.comment_added": "commented on",
|
||||
"issue.attachment_added": "attached file to",
|
||||
"issue.attachment_removed": "removed attachment from",
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from "@/lib/router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -11,13 +12,46 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
|
||||
|
||||
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
|
||||
|
||||
function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||
const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId });
|
||||
const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId });
|
||||
if (slots.length === 0 && launchers.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
|
||||
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
const globalToolbarSlotContext = useMemo(
|
||||
() => ({
|
||||
companyId: selectedCompanyId ?? null,
|
||||
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
||||
}),
|
||||
[selectedCompanyId, selectedCompany?.issuePrefix],
|
||||
);
|
||||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuButton = isMobile && (
|
||||
<Button
|
||||
@@ -34,40 +68,46 @@ export function BreadcrumbBar() {
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple breadcrumbs = breadcrumb trail
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & Runs ({timeline.length})</h3>
|
||||
|
||||
<TimelineList timeline={timeline} agentMap={agentMap} highlightCommentId={highlightCommentId} />
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
onSave: (value: string) => void | Promise<unknown>;
|
||||
as?: "h1" | "h2" | "p" | "span";
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
@@ -17,6 +16,8 @@ interface InlineEditorProps {
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
const pad = "px-1 -mx-1";
|
||||
const markdownPad = "px-1";
|
||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
|
||||
export function InlineEditor({
|
||||
value,
|
||||
@@ -29,12 +30,30 @@ export function InlineEditor({
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineFocused, setMultilineFocused] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
useEffect(() => {
|
||||
if (multiline && multilineFocused) return;
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
}, [value, multiline, multilineFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
@@ -52,58 +71,140 @@ export function InlineEditor({
|
||||
}
|
||||
}, [editing, autoSize]);
|
||||
|
||||
function commit() {
|
||||
const trimmed = draft.trim();
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
|
||||
const commit = useCallback(async (nextValue = draft) => {
|
||||
const trimmed = nextValue.trim();
|
||||
if (trimmed && trimmed !== value) {
|
||||
onSave(trimmed);
|
||||
await Promise.resolve(onSave(trimmed));
|
||||
} else {
|
||||
setDraft(value);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, onSave, value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
if (multiline) {
|
||||
return (
|
||||
<div className={cn("space-y-2", pad)}>
|
||||
<MarkdownEditor
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
contentClassName={className}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={commit}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={commit}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!multiline) return;
|
||||
if (!multilineFocused) return;
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
if (autosaveState !== "saved") {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void runSave(() => commit(trimmed));
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
markdownPad,
|
||||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => setMultilineFocused(true)}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
setMultilineFocused(false);
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={markdownRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={placeholder}
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={cn("paperclip-edit-in-place-content", className)}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-4 items-center justify-end pr-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] transition-opacity duration-150",
|
||||
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
autosaveState === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: "Idle"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
return (
|
||||
<textarea
|
||||
@@ -114,7 +215,9 @@ export function InlineEditor({
|
||||
setDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
void commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||
@@ -135,15 +238,11 @@ export function InlineEditor({
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
pad,
|
||||
!value && "text-muted-foreground italic",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value && multiline ? (
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
889
ui/src/components/IssueDocumentsSection.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
title: string;
|
||||
body: string;
|
||||
baseRevisionId: string | null;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
type DocumentConflictState = {
|
||||
key: string;
|
||||
serverDocument: IssueDocument;
|
||||
localDraft: DraftState;
|
||||
showRemote: boolean;
|
||||
};
|
||||
|
||||
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
||||
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
||||
|
||||
function loadFoldedDocumentKeys(issueId: string) {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
||||
}
|
||||
|
||||
function renderBody(body: string, className?: string) {
|
||||
return <MarkdownBody className={className}>{body}</MarkdownBody>;
|
||||
}
|
||||
|
||||
function isPlanKey(key: string) {
|
||||
return key.trim().toLowerCase() === "plan";
|
||||
}
|
||||
|
||||
function titlesMatchKey(title: string | null | undefined, key: string) {
|
||||
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isDocumentConflictError(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 409;
|
||||
}
|
||||
|
||||
function downloadDocumentFile(key: string, body: string) {
|
||||
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${key}.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
extraActions,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
} = useAutosaveIndicator();
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(issue.id),
|
||||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
||||
format: "markdown",
|
||||
body: nextDraft.body,
|
||||
baseRevisionId: nextDraft.baseRevisionId,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteDocument = useMutation({
|
||||
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setConfirmDeleteKey(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete document");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
if (a.key !== "plan" && b.key === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
||||
const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
|
||||
const newDocumentKeyError =
|
||||
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
|
||||
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
|
||||
: null;
|
||||
|
||||
const resetAutosaveState = useCallback(() => {
|
||||
setAutosaveDocumentKey(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const markDocumentDirty = useCallback((key: string) => {
|
||||
setAutosaveDocumentKey(key);
|
||||
markDirty();
|
||||
}, [markDirty]);
|
||||
|
||||
const beginNewDocument = () => {
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft({
|
||||
key: "",
|
||||
title: "",
|
||||
body: "",
|
||||
baseRevisionId: null,
|
||||
isNew: true,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const beginEdit = (key: string) => {
|
||||
const doc = sortedDocuments.find((entry) => entry.key === key);
|
||||
if (!doc) return;
|
||||
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||
resetAutosaveState();
|
||||
setDocumentConflict((current) => current?.key === key ? current : null);
|
||||
setDraft({
|
||||
key: conflictedDraft?.key ?? doc.key,
|
||||
title: conflictedDraft?.title ?? doc.title ?? "",
|
||||
body: conflictedDraft?.body ?? doc.body,
|
||||
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDocumentConflict(null);
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const commitDraft = useCallback(async (
|
||||
currentDraft: DraftState | null,
|
||||
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
|
||||
) => {
|
||||
if (!currentDraft || upsertDocument.isPending) return false;
|
||||
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
||||
const normalizedBody = currentDraft.body.trim();
|
||||
const normalizedTitle = currentDraft.title.trim();
|
||||
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
|
||||
|
||||
if (activeConflict && !options?.overrideConflict) {
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedKey || !normalizedBody) {
|
||||
if (currentDraft.isNew) {
|
||||
setError("Document key and body are required");
|
||||
} else if (!normalizedBody) {
|
||||
setError("Document body cannot be empty");
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
|
||||
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
||||
if (
|
||||
!currentDraft.isNew &&
|
||||
existing &&
|
||||
existing.body === currentDraft.body &&
|
||||
(existing.title ?? "") === currentDraft.title
|
||||
) {
|
||||
if (options?.clearAfterSave) {
|
||||
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
||||
}
|
||||
if (options?.trackAutosave) {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const saved = await upsertDocument.mutateAsync({
|
||||
...currentDraft,
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: options?.overrideConflict
|
||||
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
||||
: currentDraft.baseRevisionId,
|
||||
});
|
||||
setError(null);
|
||||
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
||||
setDraft((value) => {
|
||||
if (!value || value.key !== normalizedKey) return value;
|
||||
if (options?.clearAfterSave) return null;
|
||||
return {
|
||||
key: saved.key,
|
||||
title: saved.title ?? "",
|
||||
body: saved.body,
|
||||
baseRevisionId: saved.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.trackAutosave) {
|
||||
setAutosaveDocumentKey(normalizedKey);
|
||||
await runSave(save);
|
||||
} else {
|
||||
await save();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isDocumentConflictError(err)) {
|
||||
try {
|
||||
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
|
||||
setDocumentConflict({
|
||||
key: normalizedKey,
|
||||
serverDocument: latestDocument,
|
||||
localDraft: {
|
||||
key: normalizedKey,
|
||||
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
||||
body: currentDraft.body,
|
||||
baseRevisionId: currentDraft.baseRevisionId,
|
||||
isNew: false,
|
||||
},
|
||||
showRemote: true,
|
||||
});
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
|
||||
setError(null);
|
||||
resetAutosaveState();
|
||||
return false;
|
||||
} catch {
|
||||
setError("Document changed remotely and the latest version could not be loaded");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
|
||||
|
||||
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const serverDocument = documentConflict.serverDocument;
|
||||
setDraft({
|
||||
key: serverDocument.key,
|
||||
title: serverDocument.title ?? "",
|
||||
body: serverDocument.body,
|
||||
baseRevisionId: serverDocument.latestRevisionId,
|
||||
isNew: false,
|
||||
});
|
||||
setDocumentConflict(null);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
}, [documentConflict, resetAutosaveState]);
|
||||
|
||||
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
const sourceDraft =
|
||||
draft && draft.key === key && !draft.isNew
|
||||
? draft
|
||||
: documentConflict.localDraft;
|
||||
await commitDraft(
|
||||
{
|
||||
...sourceDraft,
|
||||
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
|
||||
},
|
||||
{
|
||||
clearAfterSave: false,
|
||||
trackAutosave: true,
|
||||
overrideConflict: true,
|
||||
},
|
||||
);
|
||||
}, [commitDraft, documentConflict, draft]);
|
||||
|
||||
const keepConflictedDraft = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
setDraft(documentConflict.localDraft);
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === key
|
||||
? { ...current, showRemote: false }
|
||||
: current,
|
||||
);
|
||||
setError(null);
|
||||
}, [documentConflict]);
|
||||
|
||||
const copyDocumentBody = useCallback(async (key: string, body: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
setCopiedDocumentKey(key);
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
copiedDocumentTimerRef.current = setTimeout(() => {
|
||||
setCopiedDocumentKey((current) => current === key ? null : current);
|
||||
}, 1400);
|
||||
} catch {
|
||||
setError("Could not copy document");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
||||
};
|
||||
|
||||
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelDraft();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
|
||||
}, [issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledToHashRef.current = false;
|
||||
}, [issue.id, location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
||||
setFoldedDocumentKeys((current) => {
|
||||
const next = current.filter((key) => validKeys.has(key));
|
||||
if (next.length !== current.length) {
|
||||
saveFoldedDocumentKeys(issue.id, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [issue.id, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
|
||||
}, [foldedDocumentKeys, issue.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentConflict) return;
|
||||
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
|
||||
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === latest.key
|
||||
? { ...current, serverDocument: latest }
|
||||
: current,
|
||||
);
|
||||
}, [documentConflict, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
||||
|| (documentKey === "plan" && Boolean(issue.legacyPlanDocument));
|
||||
if (!targetExists || hasScrolledToHashRef.current) return;
|
||||
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
|
||||
const element = document.getElementById(`document-${documentKey}`);
|
||||
if (!element) return;
|
||||
hasScrolledToHashRef.current = true;
|
||||
setHighlightDocumentKey(documentKey);
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [issue.legacyPlanDocument, location.hash, sortedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
if (copiedDocumentTimerRef.current) {
|
||||
clearTimeout(copiedDocumentTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || draft.isNew) return;
|
||||
if (documentConflict?.key === draft.key) return;
|
||||
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
||||
if (!existing) return;
|
||||
const hasChanges =
|
||||
existing.body !== draft.body ||
|
||||
(existing.title ?? "") !== draft.title;
|
||||
if (!hasChanges) {
|
||||
if (autosaveState !== "saved") {
|
||||
resetAutosaveState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDocumentDirty(draft.key);
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
autosaveDebounceRef.current = setTimeout(() => {
|
||||
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
||||
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
||||
|
||||
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
|
||||
const documentBodyPaddingClassName = "";
|
||||
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
|
||||
const toggleFoldedDocument = (key: string) => {
|
||||
setFoldedDocumentKeys((current) =>
|
||||
current.includes(key)
|
||||
? current.filter((entry) => entry !== key)
|
||||
: [...current, key],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isEmpty && !draft?.isNew ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
{draft?.isNew && (
|
||||
<div
|
||||
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
||||
onBlurCapture={handleDraftBlur}
|
||||
onKeyDown={handleDraftKeyDown}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft.key}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
||||
}
|
||||
placeholder="Document key"
|
||||
/>
|
||||
{newDocumentKeyError && (
|
||||
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
|
||||
)}
|
||||
{!isPlanKey(draft.key) && (
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
||||
}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={draft.body}
|
||||
onChange={(body) =>
|
||||
setDraft((current) => current ? { ...current, body } : current)
|
||||
}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName="min-h-[220px] text-[15px] leading-7"
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRealPlan && issue.legacyPlanDocument ? (
|
||||
<div
|
||||
id="document-plan"
|
||||
className={cn(
|
||||
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-amber-600" />
|
||||
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
|
||||
PLAN
|
||||
</span>
|
||||
</div>
|
||||
<div className={documentBodyPaddingClassName}>
|
||||
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedDocuments.map((doc) => {
|
||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
id={`document-${doc.key}`}
|
||||
className={cn(
|
||||
"rounded-lg border border-border p-3 transition-colors duration-1000",
|
||||
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={() => toggleFoldedDocument(doc.key)}
|
||||
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
|
||||
aria-expanded={!isFolded}
|
||||
>
|
||||
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<a
|
||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
</a>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
"text-muted-foreground transition-colors",
|
||||
copiedDocumentKey === doc.key && "text-foreground",
|
||||
)}
|
||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
{copiedDocumentKey === doc.key ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
title="Document actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
</DropdownMenuItem>
|
||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||
{canDeleteDocuments ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteKey(doc.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete document
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFolded ? (
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeConflict && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-200">Out of date</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This document changed while you were editing. Your local draft is preserved and autosave is paused.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDocumentConflict((current) =>
|
||||
current?.key === doc.key
|
||||
? { ...current, showRemote: !current.showRemote }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => keepConflictedDraft(doc.key)}
|
||||
>
|
||||
Keep my draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reloadDocumentFromServer(doc.key)}
|
||||
>
|
||||
Reload remote
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void overwriteDocumentFromDraft(doc.key)}
|
||||
disabled={upsertDocument.isPending}
|
||||
>
|
||||
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{activeConflict.showRemote && (
|
||||
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
|
||||
<span>•</span>
|
||||
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
|
||||
</div>
|
||||
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
||||
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
||||
) : null}
|
||||
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
||||
}}
|
||||
placeholder="Optional title"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
activeConflict
|
||||
? "text-amber-300"
|
||||
: autosaveState === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
? "Autosaving..."
|
||||
: autosaveState === "saved"
|
||||
? "Saved"
|
||||
: autosaveState === "error"
|
||||
? "Could not save"
|
||||
: ""
|
||||
: ""
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{confirmDeleteKey === doc.key && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
Delete this document? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeleteKey(null)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteDocument.mutate(doc.key)}
|
||||
disabled={deleteDocument.isPending}
|
||||
>
|
||||
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1048
ui/src/components/JsonSchemaForm.tsx
Normal file
1048
ui/src/components/JsonSchemaForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -10,6 +10,7 @@ import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
currentUserAssigneeOption,
|
||||
@@ -39,7 +40,9 @@ import {
|
||||
Tag,
|
||||
Calendar,
|
||||
Paperclip,
|
||||
FileText,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
@@ -77,7 +80,16 @@ interface IssueDraft {
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
}
|
||||
|
||||
type StagedIssueFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
kind: "document" | "attachment";
|
||||
documentKey?: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown";
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
@@ -156,6 +168,59 @@ function clearDraft() {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
|
||||
function isTextDocumentFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
name.endsWith(".txt") ||
|
||||
file.type === "text/markdown" ||
|
||||
file.type === "text/plain"
|
||||
);
|
||||
}
|
||||
|
||||
function fileBaseName(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function slugifyDocumentKey(input: string) {
|
||||
const slug = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "document";
|
||||
}
|
||||
|
||||
function titleizeFilename(input: string) {
|
||||
return input
|
||||
.split(/[-_ ]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) {
|
||||
const existingKeys = new Set(
|
||||
stagedFiles
|
||||
.filter((file) => file.kind === "document")
|
||||
.map((file) => file.documentKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
if (!existingKeys.has(baseKey)) return baseKey;
|
||||
let suffix = 2;
|
||||
while (existingKeys.has(`${baseKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
return `${baseKey}-${suffix}`;
|
||||
}
|
||||
|
||||
function formatFileSize(file: File) {
|
||||
if (file.size < 1024) return `${file.size} B`;
|
||||
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`;
|
||||
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault },
|
||||
{ value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault },
|
||||
@@ -175,6 +240,7 @@ export function NewIssueDialog() {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("todo");
|
||||
@@ -188,6 +254,8 @@ export function NewIssueDialog() {
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||
|
||||
@@ -200,7 +268,7 @@ export function NewIssueDialog() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [companyOpen, setCompanyOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stageFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -268,11 +336,49 @@ export function NewIssueDialog() {
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||
issuesApi.create(companyId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||
mutationFn: async ({
|
||||
companyId,
|
||||
stagedFiles: pendingStagedFiles,
|
||||
...data
|
||||
}: { companyId: string; stagedFiles: StagedIssueFile[] } & Record<string, unknown>) => {
|
||||
const issue = await issuesApi.create(companyId, data);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const stagedFile of pendingStagedFiles) {
|
||||
try {
|
||||
if (stagedFile.kind === "document") {
|
||||
const body = await stagedFile.file.text();
|
||||
await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", {
|
||||
title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: null,
|
||||
});
|
||||
} else {
|
||||
await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file);
|
||||
}
|
||||
} catch {
|
||||
failures.push(stagedFile.file.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { issue, companyId, failures };
|
||||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
if (failures.length > 0) {
|
||||
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
|
||||
const issueRef = issue.identifier ?? issue.id;
|
||||
pushToast({
|
||||
title: `Created ${issueRef} with upload warnings`,
|
||||
body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`,
|
||||
tone: "warn",
|
||||
action: prefix
|
||||
? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
@@ -413,6 +519,8 @@ export function NewIssueDialog() {
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setStagedFiles([]);
|
||||
setIsFileDragOver(false);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
@@ -453,6 +561,7 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
@@ -472,22 +581,70 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
const name = file.name || "image";
|
||||
setDescription((prev) => {
|
||||
const suffix = ``;
|
||||
return prev ? `${prev}\n\n${suffix}` : suffix;
|
||||
});
|
||||
} finally {
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
function stageFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setStagedFiles((current) => {
|
||||
const next = [...current];
|
||||
for (const file of files) {
|
||||
if (isTextDocumentFile(file)) {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next);
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`,
|
||||
file,
|
||||
kind: "document",
|
||||
documentKey,
|
||||
title: titleizeFilename(baseName),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: `${file.name}:${file.size}:${file.lastModified}`,
|
||||
file,
|
||||
kind: "attachment",
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleStageFilesPicked(evt: ChangeEvent<HTMLInputElement>) {
|
||||
stageFiles(Array.from(evt.target.files ?? []));
|
||||
if (stageFileInputRef.current) {
|
||||
stageFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
||||
function handleFileDragEnter(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.types.includes("Files")) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
setIsFileDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setIsFileDragOver(false);
|
||||
}
|
||||
|
||||
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
|
||||
if (!evt.dataTransfer.files.length) return;
|
||||
evt.preventDefault();
|
||||
setIsFileDragOver(false);
|
||||
stageFiles(Array.from(evt.dataTransfer.files));
|
||||
}
|
||||
|
||||
function removeStagedFile(id: string) {
|
||||
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = selectedAssigneeAgentId
|
||||
@@ -541,6 +698,8 @@ export function NewIssueDialog() {
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
@@ -938,20 +1097,103 @@ export function NewIssueDialog() {
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
{stagedDocuments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
||||
<div className="space-y-2">
|
||||
{stagedDocuments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{file.documentKey}
|
||||
</span>
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span>{file.title || file.file.name}</span>
|
||||
<span>•</span>
|
||||
<span>{formatFileSize(file.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stagedAttachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
|
||||
<div className="space-y-2">
|
||||
{stagedAttachments.map((file) => (
|
||||
<div key={file.id} className="flex items-start justify-between gap-3 rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{file.file.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{file.file.type || "application/octet-stream"} • {formatFileSize(file.file)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => removeStagedFile(file.id)}
|
||||
disabled={createIssue.isPending}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
@@ -1021,21 +1263,21 @@ export function NewIssueDialog() {
|
||||
Labels
|
||||
</button>
|
||||
|
||||
{/* Attach image chip */}
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
ref={stageFileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
accept={STAGED_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleAttachImage}
|
||||
onChange={handleStageFilesPicked}
|
||||
multiple
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={uploadDescriptionImage.isPending}
|
||||
onClick={() => stageFileInputRef.current?.click()}
|
||||
disabled={createIssue.isPending}
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
|
||||
Upload
|
||||
</button>
|
||||
|
||||
{/* More (dates) */}
|
||||
|
||||
@@ -22,6 +22,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();
|
||||
@@ -39,6 +40,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) */}
|
||||
@@ -81,6 +87,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">
|
||||
@@ -99,6 +112,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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -369,6 +369,9 @@ function invalidateActivityQueries(
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
||||
}
|
||||
|
||||
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
72
ui/src/hooks/useAutosaveIndicator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AutosaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const SAVING_DELAY_MS = 250;
|
||||
const SAVED_LINGER_MS = 1600;
|
||||
|
||||
export function useAutosaveIndicator() {
|
||||
const [state, setState] = useState<AutosaveState>("idle");
|
||||
const saveIdRef = useRef(0);
|
||||
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (savingTimerRef.current) {
|
||||
clearTimeout(savingTimerRef.current);
|
||||
savingTimerRef.current = null;
|
||||
}
|
||||
if (clearSavedTimerRef.current) {
|
||||
clearTimeout(clearSavedTimerRef.current);
|
||||
clearSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
saveIdRef.current += 1;
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
clearTimers();
|
||||
setState("idle");
|
||||
}, [clearTimers]);
|
||||
|
||||
const runSave = useCallback(async (save: () => Promise<void>) => {
|
||||
const saveId = saveIdRef.current + 1;
|
||||
saveIdRef.current = saveId;
|
||||
clearTimers();
|
||||
savingTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("saving");
|
||||
}
|
||||
}, SAVING_DELAY_MS);
|
||||
|
||||
try {
|
||||
await save();
|
||||
if (saveIdRef.current !== saveId) return;
|
||||
clearTimers();
|
||||
setState("saved");
|
||||
clearSavedTimerRef.current = setTimeout(() => {
|
||||
if (saveIdRef.current === saveId) {
|
||||
setState("idle");
|
||||
}
|
||||
}, SAVED_LINGER_MS);
|
||||
} catch (error) {
|
||||
if (saveIdRef.current !== saveId) throw error;
|
||||
clearTimers();
|
||||
setState("error");
|
||||
throw error;
|
||||
}
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
state,
|
||||
markDirty,
|
||||
reset,
|
||||
runSave,
|
||||
};
|
||||
}
|
||||
@@ -178,10 +178,16 @@
|
||||
background: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
/* Auto-hide scrollbar: transparent by default, visible on container hover */
|
||||
/* Auto-hide scrollbar: fully transparent by default, visible on container hover */
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
}
|
||||
@@ -307,6 +313,11 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.paperclip-edit-in-place-content {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -317,11 +328,11 @@
|
||||
|
||||
.paperclip-mdxeditor-content p {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content p + p {
|
||||
margin-top: 0.6rem;
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||
@@ -342,8 +353,8 @@
|
||||
|
||||
.paperclip-mdxeditor-content ul,
|
||||
.paperclip-mdxeditor-content ol {
|
||||
margin: 0.35rem 0;
|
||||
padding-left: 1.1rem;
|
||||
margin: 1.1em 0;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content ul {
|
||||
@@ -356,32 +367,46 @@
|
||||
|
||||
.paperclip-mdxeditor-content li {
|
||||
display: list-item;
|
||||
margin: 0.15rem 0;
|
||||
line-height: 1.4;
|
||||
margin: 0.3em 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h1,
|
||||
.paperclip-mdxeditor-content h2,
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0.4rem 0 0.25rem;
|
||||
.paperclip-mdxeditor-content h1 {
|
||||
margin: 0 0 0.9em;
|
||||
font-size: 1.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h2 {
|
||||
margin: 0 0 0.85em;
|
||||
font-size: 1.35em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0 0 0.8em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content img {
|
||||
max-height: 18rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content blockquote {
|
||||
margin: 0.45rem 0;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 2px solid var(--border);
|
||||
margin: 1.2em 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
|
||||
@@ -36,6 +36,8 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
@@ -84,4 +86,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -16,6 +16,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
@@ -24,6 +25,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";
|
||||
@@ -60,6 +63,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
@@ -97,6 +103,36 @@ function truncate(text: string, max: number): string {
|
||||
return text.slice(0, max - 1) + "\u2026";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: File) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown") ||
|
||||
file.type === "text/markdown"
|
||||
);
|
||||
}
|
||||
|
||||
function fileBaseName(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function slugifyDocumentKey(input: string) {
|
||||
const slug = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "document";
|
||||
}
|
||||
|
||||
function titleizeFilename(input: string) {
|
||||
return input
|
||||
.split(/[-_ ]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
@@ -130,6 +166,14 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
||||
|
||||
if (parts.length > 0) return parts.join(", ");
|
||||
}
|
||||
if (
|
||||
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||
details
|
||||
) {
|
||||
const key = typeof details.key === "string" ? details.key : "document";
|
||||
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
|
||||
}
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
@@ -160,6 +204,7 @@ export function IssueDetail() {
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -168,6 +213,7 @@ export function IssueDetail() {
|
||||
queryFn: () => issuesApi.get(issueId!),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.issues.comments(issueId!),
|
||||
@@ -257,6 +303,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>();
|
||||
@@ -384,6 +445,7 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
@@ -458,6 +520,30 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const importMarkdownDocument = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const baseName = fileBaseName(file.name);
|
||||
const key = slugifyDocumentKey(baseName);
|
||||
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
|
||||
const body = await file.text();
|
||||
const inferredTitle = titleizeFilename(baseName);
|
||||
const nextTitle = existing?.title ?? inferredTitle ?? null;
|
||||
return issuesApi.upsertDocument(issueId!, key, {
|
||||
title: key === "plan" ? null : nextTitle,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setAttachmentError(null);
|
||||
invalidateIssue();
|
||||
},
|
||||
onError: (err) => {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAttachment = useMutation({
|
||||
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
|
||||
onSuccess: () => {
|
||||
@@ -509,15 +595,62 @@ export function IssueDetail() {
|
||||
const ancestors = issue.ancestors ?? [];
|
||||
|
||||
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
const files = evt.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(false);
|
||||
const files = evt.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (isMarkdownFile(file)) {
|
||||
await importMarkdownDocument.mutateAsync(file);
|
||||
} else {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||
const attachmentList = attachments ?? [];
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
multiple
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
|
||||
className={cn(
|
||||
"shadow-none",
|
||||
attachmentDragActive && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
@@ -658,14 +791,14 @@ export function IssueDetail() {
|
||||
|
||||
<InlineEditor
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutate({ title })}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutate({ description })}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
as="p"
|
||||
className="text-[15px] leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
@@ -678,77 +811,127 @@ export function IssueDetail() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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)}
|
||||
mentions={mentionOptions}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
|
||||
/>
|
||||
|
||||
{hasAttachments ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg transition-colors",
|
||||
)}
|
||||
onDragEnter={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
evt.preventDefault();
|
||||
setAttachmentDragActive(true);
|
||||
}}
|
||||
onDragLeave={(evt) => {
|
||||
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
|
||||
setAttachmentDragActive(false);
|
||||
}}
|
||||
onDrop={(evt) => void handleAttachmentDrop(evt)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFilePicked}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadAttachment.isPending}
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
|
||||
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
|
||||
</Button>
|
||||
</div>
|
||||
{attachmentUploadButton}
|
||||
</div>
|
||||
|
||||
{attachmentError && (
|
||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||
)}
|
||||
|
||||
{(!attachments || attachments.length === 0) ? (
|
||||
<p className="text-xs text-muted-foreground">No attachments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{attachmentList.map((attachment) => (
|
||||
<div key={attachment.id} className="border border-border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={attachment.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs hover:underline truncate"
|
||||
title={attachment.originalFilename ?? attachment.id}
|
||||
>
|
||||
{attachment.originalFilename ?? attachment.id}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteAttachment.mutate(attachment.id)}
|
||||
disabled={deleteAttachment.isPending}
|
||||
title="Delete attachment"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -766,12 +949,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}`}
|
||||
@@ -844,6 +1034,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 && (
|
||||
|
||||
509
ui/src/pages/PluginManager.tsx
Normal file
509
ui/src/pages/PluginManager.tsx
Normal 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
156
ui/src/pages/PluginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
836
ui/src/pages/PluginSettings.tsx
Normal file
836
ui/src/pages/PluginSettings.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
69
ui/src/plugins/bridge-init.ts
Normal file
69
ui/src/plugins/bridge-init.ts
Normal 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
475
ui/src/plugins/bridge.ts
Normal 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 };
|
||||
}
|
||||
833
ui/src/plugins/launchers.tsx
Normal file
833
ui/src/plugins/launchers.tsx
Normal file
@@ -0,0 +1,833 @@
|
||||
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",
|
||||
"toolbarButton",
|
||||
]);
|
||||
const focusableElementSelector = [
|
||||
"button:not([disabled])",
|
||||
"[href]",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
const launcherOverlayBaseZIndex = 1000;
|
||||
const supportedLauncherBounds = new Set<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";
|
||||
case "toolbarButton":
|
||||
case "globalToolbarButton":
|
||||
return "h-8";
|
||||
default:
|
||||
return "h-8";
|
||||
}
|
||||
}
|
||||
|
||||
function launcherShellBoundsStyle(bounds: PluginLauncherBounds | null): CSSProperties {
|
||||
switch (bounds) {
|
||||
case "compact":
|
||||
return { width: "min(28rem, calc(100vw - 2rem))" };
|
||||
case "wide":
|
||||
return { width: "min(64rem, calc(100vw - 2rem))" };
|
||||
case "full":
|
||||
return { width: "calc(100vw - 2rem)", height: "calc(100vh - 2rem)" };
|
||||
case "inline":
|
||||
return { width: "min(24rem, calc(100vw - 2rem))" };
|
||||
case "default":
|
||||
default:
|
||||
return { width: "min(40rem, calc(100vw - 2rem))" };
|
||||
}
|
||||
}
|
||||
|
||||
function launcherPopoverStyle(instance: LauncherInstance): CSSProperties {
|
||||
const rect = instance.sourceRect;
|
||||
const baseWidth = launcherShellBoundsStyle(instance.bounds).width ?? "min(24rem, calc(100vw - 2rem))";
|
||||
if (!rect) {
|
||||
return {
|
||||
width: baseWidth,
|
||||
maxHeight: "min(70vh, 36rem)",
|
||||
top: "4rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
}
|
||||
|
||||
const top = Math.min(rect.bottom + 8, window.innerHeight - 32);
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 16),
|
||||
Math.max(16, window.innerWidth - 360),
|
||||
);
|
||||
|
||||
return {
|
||||
width: baseWidth,
|
||||
maxHeight: "min(70vh, 36rem)",
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
function isPluginLauncherBounds(value: unknown): value is PluginLauncherBounds {
|
||||
return typeof value === "string" && supportedLauncherBounds.has(value as PluginLauncherBounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover launchers for the requested host placement zones from the normalized
|
||||
* `/api/plugins/ui-contributions` response.
|
||||
*
|
||||
* This is the shared discovery path for toolbar, sidebar, detail-view, and
|
||||
* context-menu launchers. The hook applies host-side entity filtering and
|
||||
* returns both the sorted launcher list and a contribution map so activation
|
||||
* can stay on cached metadata.
|
||||
*/
|
||||
export function usePluginLaunchers(
|
||||
filters: UsePluginLaunchersFilters,
|
||||
): UsePluginLaunchersResult {
|
||||
const queryEnabled = filters.enabled ?? true;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.plugins.uiContributions,
|
||||
queryFn: () => pluginsApi.listUiContributions(),
|
||||
enabled: queryEnabled,
|
||||
});
|
||||
|
||||
const placementZonesKey = useMemo(
|
||||
() => [...filters.placementZones].sort().join("|"),
|
||||
[filters.placementZones],
|
||||
);
|
||||
|
||||
const contributionsByPluginId = useMemo(() => {
|
||||
const byPluginId = new Map<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" || placementZone === "globalToolbarButton" ? "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
854
ui/src/plugins/slots.tsx
Normal 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" || slotType === "toolbarButton";
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user