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_VERSION = "0.1.0";
|
||||
export const PAGE_ROUTE = "kitchensink";
|
||||
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_CONFIG,
|
||||
EXPORT_NAMES,
|
||||
JOB_KEYS,
|
||||
PAGE_ROUTE,
|
||||
PLUGIN_ID,
|
||||
PLUGIN_VERSION,
|
||||
SLOT_IDS,
|
||||
@@ -186,6 +187,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
id: SLOT_IDS.page,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.page,
|
||||
routePath: PAGE_ROUTE,
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
async function listIssuesForCompany(ctx: PluginContext, companyId: string): Promise<Issue[]> {
|
||||
return await ctx.issues.list({ companyId, limit: 20, offset: 0 });
|
||||
function getListLimit(params: Record<string, unknown>, fallback = 50): number {
|
||||
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[]> {
|
||||
return await ctx.goals.list({ companyId, limit: 20, offset: 0 });
|
||||
async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Issue[]> {
|
||||
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[] {
|
||||
@@ -249,11 +255,11 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
ctx.data.register("overview", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const config = await getConfig(ctx);
|
||||
const companies = await ctx.companies.list({ limit: 20, offset: 0 });
|
||||
const projects = companyId ? await ctx.projects.list({ companyId, limit: 20, offset: 0 }) : [];
|
||||
const issues = companyId ? await listIssuesForCompany(ctx, companyId) : [];
|
||||
const goals = companyId ? await listGoalsForCompany(ctx, companyId) : [];
|
||||
const agents = companyId ? await ctx.agents.list({ companyId, limit: 20, offset: 0 }) : [];
|
||||
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
|
||||
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
|
||||
const goals = companyId ? await listGoalsForCompany(ctx, companyId, 200) : [];
|
||||
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||
const lastJob = await readInstanceState(ctx, "last-job-run");
|
||||
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
||||
const lastAsset = await readInstanceState(ctx, "last-asset");
|
||||
@@ -287,28 +293,28 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
ctx.data.register("companies", async () => {
|
||||
return await ctx.companies.list({ limit: 50, offset: 0 });
|
||||
ctx.data.register("companies", async (params) => {
|
||||
return await ctx.companies.list({ limit: getListLimit(params), offset: 0 });
|
||||
});
|
||||
|
||||
ctx.data.register("projects", async (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) => {
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
return await listIssuesForCompany(ctx, companyId);
|
||||
return await listIssuesForCompany(ctx, companyId, getListLimit(params));
|
||||
});
|
||||
|
||||
ctx.data.register("goals", async (params) => {
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
return await listGoalsForCompany(ctx, companyId);
|
||||
return await listGoalsForCompany(ctx, companyId, getListLimit(params));
|
||||
});
|
||||
|
||||
ctx.data.register("agents", async (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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,3 +157,18 @@ export function usePluginStream<T = unknown>(
|
||||
>("usePluginStream");
|
||||
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,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
// Bridge error and host context types
|
||||
@@ -73,6 +74,10 @@ export type {
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginStreamResult,
|
||||
PluginToastTone,
|
||||
PluginToastAction,
|
||||
PluginToastInput,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
|
||||
// Slot component prop interfaces
|
||||
|
||||
@@ -300,6 +300,29 @@ export interface PluginDataResult<T = unknown> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -380,6 +380,33 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
||||
] as const;
|
||||
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
|
||||
* 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`.
|
||||
*/
|
||||
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.
|
||||
* Defaults to host-defined ordering if omitted.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
@@ -117,6 +118,9 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
displayName: z.string().min(1),
|
||||
exportName: z.string().min(1),
|
||||
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(),
|
||||
}).superRefine((value, ctx) => {
|
||||
// context-sensitive slots require explicit entity targeting.
|
||||
@@ -155,6 +159,20 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
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>;
|
||||
|
||||
@@ -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);
|
||||
log.info(
|
||||
{ pluginId, packagePath: absPath },
|
||||
|
||||
@@ -465,6 +465,26 @@ export function buildHostServices(
|
||||
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
|
||||
* required for company-scoped data access, but there is no per-company
|
||||
@@ -648,8 +668,8 @@ export function buildHostServices(
|
||||
},
|
||||
|
||||
companies: {
|
||||
async list(_params) {
|
||||
return (await companies.list()) as Company[];
|
||||
async list(params) {
|
||||
return applyWindow((await companies.list()) as Company[], params);
|
||||
},
|
||||
async get(params) {
|
||||
await ensurePluginAvailableForCompany(params.companyId);
|
||||
@@ -661,7 +681,7 @@ export function buildHostServices(
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return (await projects.list(companyId)) as Project[];
|
||||
return applyWindow((await projects.list(companyId)) as Project[], params);
|
||||
},
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -738,7 +758,7 @@ export function buildHostServices(
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.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) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -780,7 +800,10 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(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) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
@@ -825,10 +848,13 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rows = await goals.list(companyId);
|
||||
return rows.filter((goal) =>
|
||||
(!params.level || goal.level === params.level) &&
|
||||
(!params.status || goal.status === params.status),
|
||||
) as Goal[];
|
||||
return applyWindow(
|
||||
rows.filter((goal) =>
|
||||
(!params.level || goal.level === params.level) &&
|
||||
(!params.status || goal.status === params.status),
|
||||
) as Goal[],
|
||||
params,
|
||||
);
|
||||
},
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
|
||||
@@ -127,6 +127,12 @@ export interface PluginDiscoveryResult {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -739,6 +745,30 @@ export function pluginLoader(
|
||||
const log = logger.child({ service: "plugin-loader" });
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -861,6 +891,8 @@ export function pluginLoader(
|
||||
);
|
||||
}
|
||||
|
||||
await assertPageRoutePathsAvailable(manifest);
|
||||
|
||||
// Step 6: Reject plugins that require a newer host than the running server
|
||||
const minimumHostVersion = getMinimumHostVersion(manifest);
|
||||
if (minimumHostVersion && hostVersion) {
|
||||
|
||||
@@ -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