Expand kitchen sink plugin demos
This commit is contained in:
@@ -2,6 +2,7 @@ import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk";
|
|||||||
|
|
||||||
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
|
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
|
||||||
export const PLUGIN_VERSION = "0.1.0";
|
export const PLUGIN_VERSION = "0.1.0";
|
||||||
|
export const PAGE_ROUTE = "kitchensink";
|
||||||
|
|
||||||
export const SLOT_IDS = {
|
export const SLOT_IDS = {
|
||||||
page: "kitchen-sink-page",
|
page: "kitchen-sink-page",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
EXPORT_NAMES,
|
EXPORT_NAMES,
|
||||||
JOB_KEYS,
|
JOB_KEYS,
|
||||||
|
PAGE_ROUTE,
|
||||||
PLUGIN_ID,
|
PLUGIN_ID,
|
||||||
PLUGIN_VERSION,
|
PLUGIN_VERSION,
|
||||||
SLOT_IDS,
|
SLOT_IDS,
|
||||||
@@ -186,6 +187,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||||||
id: SLOT_IDS.page,
|
id: SLOT_IDS.page,
|
||||||
displayName: "Kitchen Sink",
|
displayName: "Kitchen Sink",
|
||||||
exportName: EXPORT_NAMES.page,
|
exportName: EXPORT_NAMES.page,
|
||||||
|
routePath: PAGE_ROUTE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "settingsPage",
|
type: "settingsPage",
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||||
|
const TARGET_FPS = 24;
|
||||||
|
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||||
|
|
||||||
|
const PAPERCLIP_SPRITES = [
|
||||||
|
[
|
||||||
|
" ╭────╮ ",
|
||||||
|
" ╭╯╭──╮│ ",
|
||||||
|
" │ │ ││ ",
|
||||||
|
" │ │ ││ ",
|
||||||
|
" │ │ ││ ",
|
||||||
|
" │ │ ││ ",
|
||||||
|
" │ ╰──╯│ ",
|
||||||
|
" ╰─────╯ ",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
" ╭─────╮ ",
|
||||||
|
" │╭──╮╰╮ ",
|
||||||
|
" ││ │ │ ",
|
||||||
|
" ││ │ │ ",
|
||||||
|
" ││ │ │ ",
|
||||||
|
" ││ │ │ ",
|
||||||
|
" │╰──╯ │ ",
|
||||||
|
" ╰────╯ ",
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||||
|
|
||||||
|
interface Clip {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
drift: number;
|
||||||
|
sprite: PaperclipSprite;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = "M";
|
||||||
|
span.style.cssText =
|
||||||
|
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||||
|
container.appendChild(span);
|
||||||
|
const rect = span.getBoundingClientRect();
|
||||||
|
container.removeChild(span);
|
||||||
|
return { w: rect.width, h: rect.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||||
|
let width = 0;
|
||||||
|
for (const row of sprite) width = Math.max(width, row.length);
|
||||||
|
return { width, height: sprite.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsciiArtAnimation() {
|
||||||
|
const preRef = useRef<HTMLPreElement>(null);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preRef.current) return;
|
||||||
|
const preEl: HTMLPreElement = preRef.current;
|
||||||
|
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
let isVisible = document.visibilityState !== "hidden";
|
||||||
|
let loopActive = false;
|
||||||
|
let lastRenderAt = 0;
|
||||||
|
let tick = 0;
|
||||||
|
let cols = 0;
|
||||||
|
let rows = 0;
|
||||||
|
let charW = 7;
|
||||||
|
let charH = 11;
|
||||||
|
let trail = new Float32Array(0);
|
||||||
|
let colWave = new Float32Array(0);
|
||||||
|
let rowWave = new Float32Array(0);
|
||||||
|
let clipMask = new Uint16Array(0);
|
||||||
|
let clips: Clip[] = [];
|
||||||
|
let lastOutput = "";
|
||||||
|
|
||||||
|
function toGlyph(value: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(0.999, value));
|
||||||
|
const idx = Math.floor(clamped * CHARS.length);
|
||||||
|
return CHARS[idx] ?? " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildGrid() {
|
||||||
|
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||||
|
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||||
|
if (nextCols === cols && nextRows === rows) return;
|
||||||
|
|
||||||
|
cols = nextCols;
|
||||||
|
rows = nextRows;
|
||||||
|
const cellCount = cols * rows;
|
||||||
|
trail = new Float32Array(cellCount);
|
||||||
|
colWave = new Float32Array(cols);
|
||||||
|
rowWave = new Float32Array(rows);
|
||||||
|
clipMask = new Uint16Array(cellCount);
|
||||||
|
clips = clips.filter((clip) => {
|
||||||
|
return (
|
||||||
|
clip.x > -clip.width - 2 &&
|
||||||
|
clip.x < cols + 2 &&
|
||||||
|
clip.y > -clip.height - 2 &&
|
||||||
|
clip.y < rows + 2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
lastOutput = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStaticFrame() {
|
||||||
|
if (cols <= 0 || rows <= 0) {
|
||||||
|
preEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||||
|
grid[r]![c] = toGlyph(ambient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gapX = 18;
|
||||||
|
const gapY = 13;
|
||||||
|
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||||
|
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||||
|
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||||
|
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||||
|
for (let sr = 0; sr < sprite.length; sr++) {
|
||||||
|
const line = sprite[sr]!;
|
||||||
|
for (let sc = 0; sc < line.length; sc++) {
|
||||||
|
const ch = line[sc] ?? " ";
|
||||||
|
if (ch === " ") continue;
|
||||||
|
const row = baseRow + sr;
|
||||||
|
const col = baseCol + sc;
|
||||||
|
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||||
|
grid[row]![col] = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = grid.map((line) => line.join("")).join("\n");
|
||||||
|
preEl.textContent = output;
|
||||||
|
lastOutput = output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnClip() {
|
||||||
|
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||||
|
const size = spriteSize(sprite);
|
||||||
|
const edge = Math.random();
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let vx = 0;
|
||||||
|
let vy = 0;
|
||||||
|
|
||||||
|
if (edge < 0.68) {
|
||||||
|
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||||
|
y = Math.random() * Math.max(1, rows - size.height);
|
||||||
|
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||||
|
vy = (Math.random() - 0.5) * 0.014;
|
||||||
|
} else {
|
||||||
|
x = Math.random() * Math.max(1, cols - size.width);
|
||||||
|
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||||
|
vx = (Math.random() - 0.5) * 0.014;
|
||||||
|
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||||
|
}
|
||||||
|
|
||||||
|
clips.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
life: 0,
|
||||||
|
maxLife: 260 + Math.random() * 220,
|
||||||
|
drift: (Math.random() - 0.5) * 1.2,
|
||||||
|
sprite,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampClip(clip: Clip, alpha: number) {
|
||||||
|
const baseCol = Math.round(clip.x);
|
||||||
|
const baseRow = Math.round(clip.y);
|
||||||
|
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||||
|
const line = clip.sprite[sr]!;
|
||||||
|
const row = baseRow + sr;
|
||||||
|
if (row < 0 || row >= rows) continue;
|
||||||
|
for (let sc = 0; sc < line.length; sc++) {
|
||||||
|
const ch = line[sc] ?? " ";
|
||||||
|
if (ch === " ") continue;
|
||||||
|
const col = baseCol + sc;
|
||||||
|
if (col < 0 || col >= cols) continue;
|
||||||
|
const idx = row * cols + col;
|
||||||
|
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||||
|
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||||
|
clipMask[idx] = ch.charCodeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(time: number) {
|
||||||
|
if (!loopActive) return;
|
||||||
|
frameRef.current = requestAnimationFrame(step);
|
||||||
|
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||||
|
|
||||||
|
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||||
|
lastRenderAt = time;
|
||||||
|
tick += delta;
|
||||||
|
|
||||||
|
const cellCount = cols * rows;
|
||||||
|
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||||
|
while (clips.length < targetCount) spawnClip();
|
||||||
|
|
||||||
|
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||||
|
clipMask.fill(0);
|
||||||
|
|
||||||
|
for (let i = clips.length - 1; i >= 0; i--) {
|
||||||
|
const clip = clips[i]!;
|
||||||
|
clip.life += delta;
|
||||||
|
|
||||||
|
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||||
|
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||||
|
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||||
|
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||||
|
|
||||||
|
clip.x += clip.vx * delta;
|
||||||
|
clip.y += clip.vy * delta;
|
||||||
|
|
||||||
|
if (
|
||||||
|
clip.life >= clip.maxLife ||
|
||||||
|
clip.x < -clip.width - 2 ||
|
||||||
|
clip.x > cols + 2 ||
|
||||||
|
clip.y < -clip.height - 2 ||
|
||||||
|
clip.y > rows + 2
|
||||||
|
) {
|
||||||
|
clips.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const life = clip.life / clip.maxLife;
|
||||||
|
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||||
|
stampClip(clip, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||||
|
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const idx = r * cols + c;
|
||||||
|
const clipChar = clipMask[idx];
|
||||||
|
if (clipChar > 0) {
|
||||||
|
output += String.fromCharCode(clipChar);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05;
|
||||||
|
output += toGlyph((trail[idx] ?? 0) + ambient);
|
||||||
|
}
|
||||||
|
if (r < rows - 1) output += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output !== lastOutput) {
|
||||||
|
preEl.textContent = output;
|
||||||
|
lastOutput = output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
const measured = measureChar(preEl);
|
||||||
|
charW = measured.w || 7;
|
||||||
|
charH = measured.h || 11;
|
||||||
|
rebuildGrid();
|
||||||
|
if (motionMedia.matches || !isVisible) {
|
||||||
|
drawStaticFrame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
if (loopActive) return;
|
||||||
|
loopActive = true;
|
||||||
|
lastRenderAt = 0;
|
||||||
|
frameRef.current = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop() {
|
||||||
|
loopActive = false;
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMode() {
|
||||||
|
if (motionMedia.matches || !isVisible) {
|
||||||
|
stopLoop();
|
||||||
|
drawStaticFrame();
|
||||||
|
} else {
|
||||||
|
startLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
isVisible = document.visibilityState !== "hidden";
|
||||||
|
syncMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const measured = measureChar(preEl);
|
||||||
|
charW = measured.w || 7;
|
||||||
|
charH = measured.h || 11;
|
||||||
|
rebuildGrid();
|
||||||
|
resizeObserver.observe(preEl);
|
||||||
|
motionMedia.addEventListener("change", syncMode);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
syncMode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopLoop();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
motionMedia.removeEventListener("change", syncMode);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "320px",
|
||||||
|
minHeight: "320px",
|
||||||
|
maxHeight: "350px",
|
||||||
|
background: "#1d1d1d",
|
||||||
|
color: "#f2efe6",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
padding: "14px",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||||
|
fontSize: "11px",
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "pre",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -225,12 +225,18 @@ function getCurrentCompanyId(params: Record<string, unknown>): string {
|
|||||||
return companyId;
|
return companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listIssuesForCompany(ctx: PluginContext, companyId: string): Promise<Issue[]> {
|
function getListLimit(params: Record<string, unknown>, fallback = 50): number {
|
||||||
return await ctx.issues.list({ companyId, limit: 20, offset: 0 });
|
const value = typeof params.limit === "number" ? params.limit : Number(params.limit ?? fallback);
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(1, Math.min(200, Math.floor(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGoalsForCompany(ctx: PluginContext, companyId: string): Promise<Goal[]> {
|
async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Issue[]> {
|
||||||
return await ctx.goals.list({ companyId, limit: 20, offset: 0 });
|
return await ctx.issues.list({ companyId, limit, offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGoalsForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Goal[]> {
|
||||||
|
return await ctx.goals.list({ companyId, limit, offset: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentRecordsSnapshot(): DemoRecord[] {
|
function recentRecordsSnapshot(): DemoRecord[] {
|
||||||
@@ -249,11 +255,11 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
|||||||
ctx.data.register("overview", async (params) => {
|
ctx.data.register("overview", async (params) => {
|
||||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
const config = await getConfig(ctx);
|
const config = await getConfig(ctx);
|
||||||
const companies = await ctx.companies.list({ limit: 20, offset: 0 });
|
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
|
||||||
const projects = companyId ? await ctx.projects.list({ companyId, limit: 20, offset: 0 }) : [];
|
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||||
const issues = companyId ? await listIssuesForCompany(ctx, companyId) : [];
|
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
|
||||||
const goals = companyId ? await listGoalsForCompany(ctx, companyId) : [];
|
const goals = companyId ? await listGoalsForCompany(ctx, companyId, 200) : [];
|
||||||
const agents = companyId ? await ctx.agents.list({ companyId, limit: 20, offset: 0 }) : [];
|
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||||
const lastJob = await readInstanceState(ctx, "last-job-run");
|
const lastJob = await readInstanceState(ctx, "last-job-run");
|
||||||
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
||||||
const lastAsset = await readInstanceState(ctx, "last-asset");
|
const lastAsset = await readInstanceState(ctx, "last-asset");
|
||||||
@@ -287,28 +293,28 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("companies", async () => {
|
ctx.data.register("companies", async (params) => {
|
||||||
return await ctx.companies.list({ limit: 50, offset: 0 });
|
return await ctx.companies.list({ limit: getListLimit(params), offset: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("projects", async (params) => {
|
ctx.data.register("projects", async (params) => {
|
||||||
const companyId = getCurrentCompanyId(params);
|
const companyId = getCurrentCompanyId(params);
|
||||||
return await ctx.projects.list({ companyId, limit: 50, offset: 0 });
|
return await ctx.projects.list({ companyId, limit: getListLimit(params), offset: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("issues", async (params) => {
|
ctx.data.register("issues", async (params) => {
|
||||||
const companyId = getCurrentCompanyId(params);
|
const companyId = getCurrentCompanyId(params);
|
||||||
return await listIssuesForCompany(ctx, companyId);
|
return await listIssuesForCompany(ctx, companyId, getListLimit(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("goals", async (params) => {
|
ctx.data.register("goals", async (params) => {
|
||||||
const companyId = getCurrentCompanyId(params);
|
const companyId = getCurrentCompanyId(params);
|
||||||
return await listGoalsForCompany(ctx, companyId);
|
return await listGoalsForCompany(ctx, companyId, getListLimit(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("agents", async (params) => {
|
ctx.data.register("agents", async (params) => {
|
||||||
const companyId = getCurrentCompanyId(params);
|
const companyId = getCurrentCompanyId(params);
|
||||||
return await ctx.agents.list({ companyId, limit: 50, offset: 0 });
|
return await ctx.agents.list({ companyId, limit: getListLimit(params), offset: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register("workspaces", async (params) => {
|
ctx.data.register("workspaces", async (params) => {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { PluginDataResult, PluginActionFn, PluginHostContext, PluginStreamResult } from "./types.js";
|
import type {
|
||||||
|
PluginDataResult,
|
||||||
|
PluginActionFn,
|
||||||
|
PluginHostContext,
|
||||||
|
PluginStreamResult,
|
||||||
|
PluginToastFn,
|
||||||
|
} from "./types.js";
|
||||||
import { getSdkUiRuntimeValue } from "./runtime.js";
|
import { getSdkUiRuntimeValue } from "./runtime.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,3 +157,18 @@ export function usePluginStream<T = unknown>(
|
|||||||
>("usePluginStream");
|
>("usePluginStream");
|
||||||
return impl(channel, options);
|
return impl(channel, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginToast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a host toast notification from plugin UI.
|
||||||
|
*
|
||||||
|
* This lets plugin pages and widgets surface user-facing feedback through the
|
||||||
|
* same toast system as the host app without reaching into host internals.
|
||||||
|
*/
|
||||||
|
export function usePluginToast(): PluginToastFn {
|
||||||
|
const impl = getSdkUiRuntimeValue<() => PluginToastFn>("usePluginToast");
|
||||||
|
return impl();
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export {
|
|||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
|
usePluginToast,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
|
|
||||||
// Bridge error and host context types
|
// Bridge error and host context types
|
||||||
@@ -73,6 +74,10 @@ export type {
|
|||||||
PluginDataResult,
|
PluginDataResult,
|
||||||
PluginActionFn,
|
PluginActionFn,
|
||||||
PluginStreamResult,
|
PluginStreamResult,
|
||||||
|
PluginToastTone,
|
||||||
|
PluginToastAction,
|
||||||
|
PluginToastInput,
|
||||||
|
PluginToastFn,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
// Slot component prop interfaces
|
// Slot component prop interfaces
|
||||||
|
|||||||
@@ -300,6 +300,29 @@ export interface PluginDataResult<T = unknown> {
|
|||||||
refresh(): void;
|
refresh(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginToast hook types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PluginToastTone = "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface PluginToastAction {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginToastInput {
|
||||||
|
id?: string;
|
||||||
|
dedupeKey?: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
tone?: PluginToastTone;
|
||||||
|
ttlMs?: number;
|
||||||
|
action?: PluginToastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// usePluginAction hook return type
|
// usePluginAction hook return type
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -380,6 +380,33 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved company-scoped route segments that plugin page routes may not claim.
|
||||||
|
*
|
||||||
|
* These map to first-class host pages under `/:companyPrefix/...`.
|
||||||
|
*/
|
||||||
|
export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
|
||||||
|
"dashboard",
|
||||||
|
"onboarding",
|
||||||
|
"companies",
|
||||||
|
"company",
|
||||||
|
"settings",
|
||||||
|
"plugins",
|
||||||
|
"org",
|
||||||
|
"agents",
|
||||||
|
"projects",
|
||||||
|
"issues",
|
||||||
|
"goals",
|
||||||
|
"approvals",
|
||||||
|
"costs",
|
||||||
|
"activity",
|
||||||
|
"inbox",
|
||||||
|
"design-guide",
|
||||||
|
"tests",
|
||||||
|
] as const;
|
||||||
|
export type PluginReservedCompanyRouteSegment =
|
||||||
|
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launcher placement zones describe where a plugin-owned launcher can appear
|
* Launcher placement zones describe where a plugin-owned launcher can appear
|
||||||
* in the host UI. These are intentionally aligned with current slot surfaces
|
* in the host UI. These are intentionally aligned with current slot surfaces
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export interface PluginUiSlotDeclaration {
|
|||||||
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
|
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
|
||||||
*/
|
*/
|
||||||
entityTypes?: PluginUiSlotEntityType[];
|
entityTypes?: PluginUiSlotEntityType[];
|
||||||
|
/**
|
||||||
|
* Optional company-scoped route segment for page slots.
|
||||||
|
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||||
|
*/
|
||||||
|
routePath?: string;
|
||||||
/**
|
/**
|
||||||
* Optional ordering hint within a slot surface. Lower numbers appear first.
|
* Optional ordering hint within a slot surface. Lower numbers appear first.
|
||||||
* Defaults to host-defined ordering if omitted.
|
* Defaults to host-defined ordering if omitted.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PLUGIN_CAPABILITIES,
|
PLUGIN_CAPABILITIES,
|
||||||
PLUGIN_UI_SLOT_TYPES,
|
PLUGIN_UI_SLOT_TYPES,
|
||||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||||
|
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
|
||||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||||
PLUGIN_LAUNCHER_ACTIONS,
|
PLUGIN_LAUNCHER_ACTIONS,
|
||||||
PLUGIN_LAUNCHER_BOUNDS,
|
PLUGIN_LAUNCHER_BOUNDS,
|
||||||
@@ -117,6 +118,9 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
|||||||
displayName: z.string().min(1),
|
displayName: z.string().min(1),
|
||||||
exportName: z.string().min(1),
|
exportName: z.string().min(1),
|
||||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||||
|
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
|
||||||
|
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
|
||||||
|
}).optional(),
|
||||||
order: z.number().int().optional(),
|
order: z.number().int().optional(),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
// context-sensitive slots require explicit entity targeting.
|
// context-sensitive slots require explicit entity targeting.
|
||||||
@@ -155,6 +159,20 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
|||||||
path: ["entityTypes"],
|
path: ["entityTypes"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (value.routePath && value.type !== "page") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "routePath is only supported for page slots",
|
||||||
|
path: ["routePath"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `routePath "${value.routePath}" is reserved by the host`,
|
||||||
|
path: ["routePath"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
|
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ export function createPluginDevWatcher(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watcher.on("error", (err) => {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId, packagePath: absPath, err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"plugin-dev-watcher: watcher error, stopping watch for this plugin",
|
||||||
|
);
|
||||||
|
unwatchPlugin(pluginId);
|
||||||
|
});
|
||||||
|
|
||||||
watchers.set(pluginId, watcher);
|
watchers.set(pluginId, watcher);
|
||||||
log.info(
|
log.info(
|
||||||
{ pluginId, packagePath: absPath },
|
{ pluginId, packagePath: absPath },
|
||||||
|
|||||||
@@ -465,6 +465,26 @@ export function buildHostServices(
|
|||||||
return companyId;
|
return companyId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseWindowValue = (value: unknown): number | null => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return Math.max(0, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyWindow = <T>(rows: T[], params?: { limit?: unknown; offset?: unknown }): T[] => {
|
||||||
|
const offset = parseWindowValue(params?.offset) ?? 0;
|
||||||
|
const limit = parseWindowValue(params?.limit);
|
||||||
|
if (limit == null) return rows.slice(offset);
|
||||||
|
return rows.slice(offset, offset + limit);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugins are instance-wide in the current runtime. Company IDs are still
|
* Plugins are instance-wide in the current runtime. Company IDs are still
|
||||||
* required for company-scoped data access, but there is no per-company
|
* required for company-scoped data access, but there is no per-company
|
||||||
@@ -648,8 +668,8 @@ export function buildHostServices(
|
|||||||
},
|
},
|
||||||
|
|
||||||
companies: {
|
companies: {
|
||||||
async list(_params) {
|
async list(params) {
|
||||||
return (await companies.list()) as Company[];
|
return applyWindow((await companies.list()) as Company[], params);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
await ensurePluginAvailableForCompany(params.companyId);
|
await ensurePluginAvailableForCompany(params.companyId);
|
||||||
@@ -661,7 +681,7 @@ export function buildHostServices(
|
|||||||
async list(params) {
|
async list(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
return (await projects.list(companyId)) as Project[];
|
return applyWindow((await projects.list(companyId)) as Project[], params);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
@@ -738,7 +758,7 @@ export function buildHostServices(
|
|||||||
async list(params) {
|
async list(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
return (await issues.list(companyId, params as any)) as Issue[];
|
return applyWindow((await issues.list(companyId, params as any)) as Issue[], params);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
@@ -780,7 +800,10 @@ export function buildHostServices(
|
|||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
const rows = await agents.list(companyId);
|
const rows = await agents.list(companyId);
|
||||||
return rows.filter((agent) => !params.status || agent.status === params.status) as Agent[];
|
return applyWindow(
|
||||||
|
rows.filter((agent) => !params.status || agent.status === params.status) as Agent[],
|
||||||
|
params,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
@@ -825,10 +848,13 @@ export function buildHostServices(
|
|||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
const rows = await goals.list(companyId);
|
const rows = await goals.list(companyId);
|
||||||
return rows.filter((goal) =>
|
return applyWindow(
|
||||||
(!params.level || goal.level === params.level) &&
|
rows.filter((goal) =>
|
||||||
(!params.status || goal.status === params.status),
|
(!params.level || goal.level === params.level) &&
|
||||||
) as Goal[];
|
(!params.status || goal.status === params.status),
|
||||||
|
) as Goal[],
|
||||||
|
params,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async get(params) {
|
async get(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ export interface PluginDiscoveryResult {
|
|||||||
sources: PluginSource[];
|
sources: PluginSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[] {
|
||||||
|
return (manifest.ui?.slots ?? [])
|
||||||
|
.filter((slot): slot is PluginUiSlotDeclaration => slot.type === "page" && typeof slot.routePath === "string" && slot.routePath.length > 0)
|
||||||
|
.map((slot) => slot.routePath!);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Loader options
|
// Loader options
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -739,6 +745,30 @@ export function pluginLoader(
|
|||||||
const log = logger.child({ service: "plugin-loader" });
|
const log = logger.child({ service: "plugin-loader" });
|
||||||
const hostVersion = runtimeServices?.instanceInfo.hostVersion;
|
const hostVersion = runtimeServices?.instanceInfo.hostVersion;
|
||||||
|
|
||||||
|
async function assertPageRoutePathsAvailable(manifest: PaperclipPluginManifestV1): Promise<void> {
|
||||||
|
const requestedRoutePaths = getDeclaredPageRoutePaths(manifest);
|
||||||
|
if (requestedRoutePaths.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueRequested = new Set(requestedRoutePaths);
|
||||||
|
if (uniqueRequested.size !== requestedRoutePaths.length) {
|
||||||
|
throw new Error(`Plugin ${manifest.id} declares duplicate page routePath values`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedPlugins = await registry.listInstalled();
|
||||||
|
for (const plugin of installedPlugins) {
|
||||||
|
if (plugin.pluginKey === manifest.id) continue;
|
||||||
|
const installedManifest = plugin.manifestJson as PaperclipPluginManifestV1 | null;
|
||||||
|
if (!installedManifest) continue;
|
||||||
|
const installedRoutePaths = new Set(getDeclaredPageRoutePaths(installedManifest));
|
||||||
|
const conflictingRoute = requestedRoutePaths.find((routePath) => installedRoutePaths.has(routePath));
|
||||||
|
if (conflictingRoute) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin ${manifest.id} routePath "${conflictingRoute}" conflicts with installed plugin ${plugin.pluginKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -861,6 +891,8 @@ export function pluginLoader(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertPageRoutePathsAvailable(manifest);
|
||||||
|
|
||||||
// Step 6: Reject plugins that require a newer host than the running server
|
// Step 6: Reject plugins that require a newer host than the running server
|
||||||
const minimumHostVersion = getMinimumHostVersion(manifest);
|
const minimumHostVersion = getMinimumHostVersion(manifest);
|
||||||
if (minimumHostVersion && hostVersion) {
|
if (minimumHostVersion && hostVersion) {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function boardRoutes() {
|
|||||||
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||||
|
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
<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
|
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||||
*/
|
*/
|
||||||
export function PluginPage() {
|
export function PluginPage() {
|
||||||
const { companyPrefix: routeCompanyPrefix, pluginId } = useParams<{
|
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
|
||||||
companyPrefix?: string;
|
companyPrefix?: string;
|
||||||
pluginId: string;
|
pluginId?: string;
|
||||||
|
pluginRoutePath?: string;
|
||||||
}>();
|
}>();
|
||||||
const { companies, selectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
@@ -39,23 +40,39 @@ export function PluginPage() {
|
|||||||
const { data: contributions } = useQuery({
|
const { data: contributions } = useQuery({
|
||||||
queryKey: queryKeys.plugins.uiContributions,
|
queryKey: queryKeys.plugins.uiContributions,
|
||||||
queryFn: () => pluginsApi.listUiContributions(),
|
queryFn: () => pluginsApi.listUiContributions(),
|
||||||
enabled: !!resolvedCompanyId && !!pluginId,
|
enabled: !!resolvedCompanyId && (!!pluginId || !!pluginRoutePath),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageSlot = useMemo(() => {
|
const pageSlot = useMemo(() => {
|
||||||
if (!pluginId || !contributions) return null;
|
if (!contributions) return null;
|
||||||
const contribution = contributions.find((c) => c.pluginId === pluginId);
|
if (pluginId) {
|
||||||
if (!contribution) return null;
|
const contribution = contributions.find((c) => c.pluginId === pluginId);
|
||||||
const slot = contribution.slots.find((s) => s.type === "page");
|
if (!contribution) return null;
|
||||||
if (!slot) return null;
|
const slot = contribution.slots.find((s) => s.type === "page");
|
||||||
return {
|
if (!slot) return null;
|
||||||
...slot,
|
return {
|
||||||
pluginId: contribution.pluginId,
|
...slot,
|
||||||
pluginKey: contribution.pluginKey,
|
pluginId: contribution.pluginId,
|
||||||
pluginDisplayName: contribution.displayName,
|
pluginKey: contribution.pluginKey,
|
||||||
pluginVersion: contribution.version,
|
pluginDisplayName: contribution.displayName,
|
||||||
};
|
pluginVersion: contribution.version,
|
||||||
}, [pluginId, contributions]);
|
};
|
||||||
|
}
|
||||||
|
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(
|
const context = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -86,9 +103,22 @@ export function PluginPage() {
|
|||||||
return <div className="text-sm text-muted-foreground">Loading…</div>;
|
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 (!pageSlot) {
|
||||||
// No page slot: redirect to plugin settings where plugin info is always shown
|
// 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 />;
|
return <Navigate to={settingsPath} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
usePluginStream,
|
||||||
|
usePluginToast,
|
||||||
} from "./bridge.js";
|
} from "./bridge.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,6 +64,8 @@ export function initPluginBridge(
|
|||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
usePluginStream,
|
||||||
|
usePluginToast,
|
||||||
|
|
||||||
// Placeholder shared UI components — plugins that use these will get
|
// Placeholder shared UI components — plugins that use these will get
|
||||||
// functional stubs. Full implementations matching the host's design
|
// functional stubs. Full implementations matching the host's design
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { pluginsApi } from "@/api/plugins";
|
import { pluginsApi } from "@/api/plugins";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
|
import { useToast, type ToastInput } from "@/context/ToastContext";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bridge error type (mirrors the SDK's PluginBridgeError)
|
// Bridge error type (mirrors the SDK's PluginBridgeError)
|
||||||
@@ -59,6 +60,9 @@ export interface PluginDataResult<T = unknown> {
|
|||||||
refresh(): void;
|
refresh(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginToastInput = ToastInput;
|
||||||
|
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Host context type (mirrors the SDK's PluginHostContext)
|
// Host context type (mirrors the SDK's PluginHostContext)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -359,3 +363,113 @@ export function useHostContext(): PluginHostContext {
|
|||||||
const { hostContext } = usePluginBridgeContext();
|
const { hostContext } = usePluginBridgeContext();
|
||||||
return hostContext;
|
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":
|
case "sdk-ui":
|
||||||
source = `
|
source = `
|
||||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream,
|
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||||
Spinner, ErrorBoundary } = SDK;
|
Spinner, ErrorBoundary } = SDK;
|
||||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream,
|
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
|
||||||
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
MetricCard, StatusBadge, DataTable, TimeseriesChart,
|
||||||
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
|
||||||
Spinner, ErrorBoundary };
|
Spinner, ErrorBoundary };
|
||||||
|
|||||||
Reference in New Issue
Block a user