Expand kitchen sink plugin demos
This commit is contained in:
@@ -155,6 +155,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" />} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,10 @@ import { ArrowLeft } from "lucide-react";
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||
*/
|
||||
export function PluginPage() {
|
||||
const { companyPrefix: routeCompanyPrefix, pluginId } = useParams<{
|
||||
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
|
||||
companyPrefix?: string;
|
||||
pluginId: string;
|
||||
pluginId?: string;
|
||||
pluginRoutePath?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -39,23 +40,39 @@ export function PluginPage() {
|
||||
const { data: contributions } = useQuery({
|
||||
queryKey: queryKeys.plugins.uiContributions,
|
||||
queryFn: () => pluginsApi.listUiContributions(),
|
||||
enabled: !!resolvedCompanyId && !!pluginId,
|
||||
enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath),
|
||||
});
|
||||
|
||||
const pageSlot = useMemo(() => {
|
||||
if (!pluginId || !contributions) return null;
|
||||
const contribution = contributions.find((c) => c.pluginId === pluginId);
|
||||
if (!contribution) return null;
|
||||
const slot = contribution.slots.find((s) => s.type === "page");
|
||||
if (!slot) return null;
|
||||
return {
|
||||
...slot,
|
||||
pluginId: contribution.pluginId,
|
||||
pluginKey: contribution.pluginKey,
|
||||
pluginDisplayName: contribution.displayName,
|
||||
pluginVersion: contribution.version,
|
||||
};
|
||||
}, [pluginId, contributions]);
|
||||
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(
|
||||
() => ({
|
||||
@@ -86,9 +103,22 @@ export function PluginPage() {
|
||||
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) {
|
||||
// No page slot: redirect to plugin settings where plugin info is always shown
|
||||
const settingsPath = `/instance/settings/plugins/${pluginId}`;
|
||||
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
|
||||
return <Navigate to={settingsPath} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./bridge.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,6 +64,8 @@ export function initPluginBridge(
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
|
||||
// Placeholder shared UI components — plugins that use these will get
|
||||
// functional stubs. Full implementations matching the host's design
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
} 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)
|
||||
@@ -59,6 +60,9 @@ export interface PluginDataResult<T = unknown> {
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
export type PluginToastInput = ToastInput;
|
||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host context type (mirrors the SDK's PluginHostContext)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -359,3 +363,113 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -257,11 +257,11 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
|
||||
case "sdk-ui":
|
||||
source = `
|
||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream,
|
||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||
Spinner, ErrorBoundary } = SDK;
|
||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream,
|
||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||
Spinner, ErrorBoundary };
|
||||
|
||||
Reference in New Issue
Block a user