mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board reads API data and keeps the current task on screen. > - A restart or temporary network failure can interrupt those reads. > - Separate page error handlers can hide cached data or expose raw server codes. > - The app needs one connection state, readable error copy and a consistent read policy. > - This change provides that foundation and moves affected read surfaces onto it. > - The dependent write layer in #17960 preserves edits and submissions across the same failures. ## Linked Issues or Issue Description **What happened?** Temporary API failures could replace loaded content with errors. Different surfaces handled the same outage differently. Some showed raw server codes. A successful query to an unrelated service could also falsely report that Paperclip had recovered. **Expected behavior** Keep cached content visible. Show one connection banner during sustained trouble. Confirm recovery through the Paperclip health probe or its WebSocket. Retry transient reads and show readable errors for permanent failures. **Steps to reproduce** 1. Open a populated task or workspace page. 2. Interrupt API requests with a 503 or a dropped connection. 3. Keep an unrelated external request in flight. 4. Confirm that cached content remains and that the external result cannot clear the outage. 5. Restore the API and check that reads refresh. The write layer first merged into this feature branch in #15769 now has its own dependent PR, #17960. This PR contains the shared foundation and read changes. Merge this PR first. ## What Changed - Classify API errors and provide readable error copy, including non-JSON proxy responses and Retry-After handling. - Use one connection store and health probe with bounded backoff. Confirm recovery from the server health probe or live-updates WebSocket. - Pause reads while disconnected, retry transient errors, and refresh active reads after recovery. - Keep cached data visible through shared query views and affected page, panel and plugin read surfaces. - Add one app-level connection banner and support the pending-write count consumed by the dependent write layer. - Add a query-error rendering guard. Call it directly from the existing CI step. Update development documentation. ## Verification - Historical-chat lookup now follows the same ready/stale roster state as rendering. Three regressions fail before the fix; the six new cases pass after it, including working Retry, fresh data, no-cache error, permanent 403 and company isolation. All six focused query/recovery suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no unresolved findings. Canary Dry Run initially refused a preview version that was already on npm. It passed after one rerun with fresh registry metadata; no check was disabled. - Integrated current master and retained both transcript interest registration and connectivity reporting on socket open. The seven focused recovery/query suites pass 288 tests, and UI typecheck and production build pass. All 9,451 UI tests across 799 files pass on the merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved findings. - Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI checks pass. Apex is **5/5** on that head with no unresolved findings. The branch has no merge conflict. GitHub still requires code-owner approval. - The foundation passes 9,430 UI tests in 804 files. After integrating current master page paths, 190 focused page tests pass. - Two unrelated-success regressions fail before the latest fix. With the fix, 62 focused UI tests, all six guard tests and UI TypeScript pass. - The composed foundation and write layer pass **9,505 UI tests in 808 files**, UI TypeScript, a production UI build, and query-error, token and map guards. - A real Chromium journey on the composed source injects a 503 during title save. The loaded task stays visible; one reconnect banner appears; the edit saves after recovery; another task stays unchanged; the saved title survives reload. - Full recursive local checks were not repeated because Runner compilation exhausted local disk during another repair. Hosted checks must pass before merge. No new staging deployment is claimed. - Latest review follow-up: a cached CaseDetail now survives a failed HTTP 429 refresh. Its page regression fails before the fix and passes after it. Eighty focused foundation tests, UI TypeScript, build and guards pass after that change. - Earlier CI evidence is retained: several foundation jobs received a runner shutdown signal together. The write-layer head also failed an unchanged Runner descriptor close test with `provider_transport_failed`. The same Runner source passed that suite in the foundation run. New CI runs cover the updated heads; no check or assertion was weakened. ## Risks - Shared retry defaults change the time before transient read errors appear. Permanent errors still fail promptly. - ApiError.message now contains readable copy. Callers that compare raw codes must use ApiError.code. - Cache success can come from another service or local data. It must not confirm Paperclip recovery. - Durable writes are reviewed separately in #17960. This PR does not change database or mutation contracts. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, tests and review fixes. The Codex session does not expose its exact serving model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
694 lines
22 KiB
TypeScript
694 lines
22 KiB
TypeScript
/**
|
|
* 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, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
import { useLocation as useRouterLocation, useNavigate as useRouterNavigate, type NavigateOptions } from "react-router-dom";
|
|
import type {
|
|
PluginBridgeErrorCode,
|
|
PluginLauncherBounds,
|
|
PluginLauncherRenderContextSnapshot,
|
|
PluginLauncherRenderEnvironment,
|
|
} from "@paperclipai/shared";
|
|
import { pluginsApi } from "@/api/plugins";
|
|
import { ApiError } from "@/api/client";
|
|
import { isTransientError } from "@/api/errors";
|
|
import { retryDelayFor, shouldRetryRequest } from "@/lib/query-client";
|
|
import { useToastActions, type ToastInput } from "@/context/ToastContext";
|
|
import { useSidebar } from "@/context/SidebarContext";
|
|
import { isGlobalPath, normalizeCompanyPrefix } from "@/lib/company-routes";
|
|
import { normalizeRememberedInstanceSettingsPath } from "@/lib/instance-settings";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
export interface HostNavigationOptions {
|
|
replace?: boolean;
|
|
state?: unknown;
|
|
}
|
|
|
|
export interface HostNavigationLinkOptions extends HostNavigationOptions {
|
|
target?: string;
|
|
rel?: string;
|
|
}
|
|
|
|
export interface HostNavigationLinkProps {
|
|
href: string;
|
|
target?: string;
|
|
rel?: string;
|
|
onClick(event: ReactMouseEvent<HTMLAnchorElement>): void;
|
|
}
|
|
|
|
export interface HostNavigation {
|
|
resolveHref(to: string): string;
|
|
navigate(to: string, options?: HostNavigationOptions): void;
|
|
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
|
|
}
|
|
|
|
export interface HostLocation {
|
|
pathname: string;
|
|
search: string;
|
|
hash: string;
|
|
state?: unknown;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) : "";
|
|
}
|
|
|
|
function splitPath(path: string): { pathname: string; search: string; hash: string } {
|
|
const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
|
return {
|
|
pathname: match?.[1] ?? path,
|
|
search: match?.[2] ?? "",
|
|
hash: match?.[3] ?? "",
|
|
};
|
|
}
|
|
|
|
function sameOriginPathFromHref(href: string): string | null {
|
|
if (!/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
|
return href;
|
|
}
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
const url = new URL(href, window.location.origin);
|
|
if (url.origin !== window.location.origin) return null;
|
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasCompanyPrefix(pathname: string, companyPrefix: string): boolean {
|
|
const [firstSegment] = pathname.split("/").filter(Boolean);
|
|
return firstSegment?.toUpperCase() === normalizeCompanyPrefix(companyPrefix);
|
|
}
|
|
|
|
function isLegacyInstanceSettingsPath(pathname: string): boolean {
|
|
return (
|
|
pathname === "/instance" ||
|
|
pathname === "/instance/settings" ||
|
|
pathname.startsWith("/instance/settings/") ||
|
|
pathname === "/settings" ||
|
|
pathname.startsWith("/settings/")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve a plugin-provided Paperclip path to the active company scope.
|
|
*
|
|
* This intentionally handles plugin page roots such as `/wiki`, which cannot
|
|
* be listed in the host router's static board-route table ahead of time.
|
|
*/
|
|
export function resolveHostNavigationHref(
|
|
to: string,
|
|
companyPrefix: string | null | undefined,
|
|
): string {
|
|
const sameOriginPath = sameOriginPathFromHref(to);
|
|
if (sameOriginPath === null) return to;
|
|
|
|
const { pathname, search, hash } = splitPath(sameOriginPath);
|
|
if (isLegacyInstanceSettingsPath(pathname)) {
|
|
const canonicalPath = normalizeRememberedInstanceSettingsPath(`${pathname}${search}${hash}`);
|
|
if (!companyPrefix) return canonicalPath;
|
|
return `/${normalizeCompanyPrefix(companyPrefix)}${canonicalPath}`;
|
|
}
|
|
|
|
if (!pathname.startsWith("/") || isGlobalPath(pathname) || !companyPrefix) {
|
|
return sameOriginPath;
|
|
}
|
|
|
|
if (hasCompanyPrefix(pathname, companyPrefix)) {
|
|
return sameOriginPath;
|
|
}
|
|
|
|
return `/${normalizeCompanyPrefix(companyPrefix)}${pathname}${search}${hash}`;
|
|
}
|
|
|
|
function isPlainLeftClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
|
return (
|
|
!event.defaultPrevented &&
|
|
event.button === 0 &&
|
|
!event.metaKey &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.shiftKey
|
|
);
|
|
}
|
|
|
|
export function shouldHandleHostNavigationClick(
|
|
event: ReactMouseEvent<HTMLAnchorElement>,
|
|
href: string,
|
|
target?: string,
|
|
): boolean {
|
|
if (!isPlainLeftClick(event)) return false;
|
|
if (target && target !== "_self") return false;
|
|
if (event.currentTarget.hasAttribute("download")) return false;
|
|
return sameOriginPathFromHref(href) !== null;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
/**
|
|
* The identity of the request whose successful response `data` holds, or
|
|
* null. Data is kept through an outage only for that same request: once
|
|
* `key`, `params`, the company, or the render environment change, the old
|
|
* value belongs to a different request and must not stand in for the new one.
|
|
*/
|
|
const dataRequestKey = useRef<string | null>(null);
|
|
|
|
// Stable serialization for params change detection
|
|
const paramsKey = serializeParams(params);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let failureCount = 0;
|
|
const requestKey = [pluginId, companyId ?? "", key, paramsKey, renderEnvironmentKey].join("\u0000");
|
|
setLoading(true);
|
|
const request = () => {
|
|
pluginsApi
|
|
.bridgeGetData(
|
|
pluginId,
|
|
key,
|
|
params,
|
|
companyId,
|
|
renderEnvironmentSnapshot,
|
|
)
|
|
.then((response) => {
|
|
if (!cancelled) {
|
|
dataRequestKey.current = requestKey;
|
|
setData(response.data as T);
|
|
setError(null);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (cancelled) return;
|
|
|
|
// The same policy as every other read: transient failures (a worker
|
|
// restarting, a gateway blip) retry with backoff, client errors do not.
|
|
if (shouldRetryRequest(failureCount, err)) {
|
|
const delay = retryDelayFor(failureCount, err);
|
|
failureCount += 1;
|
|
retryTimer = setTimeout(() => {
|
|
retryTimer = null;
|
|
if (!cancelled) request();
|
|
}, delay);
|
|
return;
|
|
}
|
|
|
|
setLoading(false);
|
|
if (isTransientError(err) && dataRequestKey.current === requestKey) {
|
|
// Keep the last good data for this same request through an
|
|
// outage. The app-level connection banner explains why it is not
|
|
// updating.
|
|
return;
|
|
}
|
|
dataRequestKey.current = null;
|
|
setError(extractBridgeError(err));
|
|
setData(null);
|
|
});
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// useHostNavigation — concrete implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useHostNavigation(): HostNavigation {
|
|
const { hostContext } = usePluginBridgeContext();
|
|
const routerNavigate = useRouterNavigate();
|
|
const { isMobile, setSidebarOpen } = useSidebar();
|
|
const companyPrefix = hostContext.companyPrefix;
|
|
|
|
const resolveHref = useCallback(
|
|
(to: string) => resolveHostNavigationHref(to, companyPrefix),
|
|
[companyPrefix],
|
|
);
|
|
|
|
const navigate = useCallback(
|
|
(to: string, options?: HostNavigationOptions) => {
|
|
const href = resolveHref(to);
|
|
const sameOriginPath = sameOriginPathFromHref(href);
|
|
if (sameOriginPath === null) {
|
|
window.location.assign(href);
|
|
return;
|
|
}
|
|
routerNavigate(sameOriginPath, options as NavigateOptions | undefined);
|
|
// Mirror host sidebar behavior: tapping a link inside the mobile drawer
|
|
// dismisses the drawer so the user can see the destination page.
|
|
if (isMobile) setSidebarOpen(false);
|
|
},
|
|
[isMobile, resolveHref, routerNavigate, setSidebarOpen],
|
|
);
|
|
|
|
const linkProps = useCallback(
|
|
(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps => {
|
|
const href = resolveHref(to);
|
|
return {
|
|
href,
|
|
target: options?.target,
|
|
rel: options?.rel,
|
|
onClick: (event) => {
|
|
if (!shouldHandleHostNavigationClick(event, href, options?.target)) return;
|
|
event.preventDefault();
|
|
navigate(href, options);
|
|
},
|
|
};
|
|
},
|
|
[navigate, resolveHref],
|
|
);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
resolveHref,
|
|
navigate,
|
|
linkProps,
|
|
}),
|
|
[linkProps, navigate, resolveHref],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// useHostLocation — concrete implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useHostLocation(): HostLocation {
|
|
const location = useRouterLocation();
|
|
return useMemo(
|
|
() => ({
|
|
pathname: location.pathname,
|
|
search: location.search,
|
|
hash: location.hash,
|
|
state: location.state,
|
|
}),
|
|
[location.hash, location.pathname, location.search, location.state],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// usePluginToast — concrete implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function usePluginToast(): PluginToastFn {
|
|
const { pushToast } = useToastActions();
|
|
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 };
|
|
}
|