Expand kitchen sink plugin demos

This commit is contained in:
Dotta
2026-03-14 09:26:45 -05:00
parent 6fa1dd2197
commit cb5d7e76fb
19 changed files with 1602 additions and 116 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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) => {

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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.

View File

@@ -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>;

View File

@@ -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 },

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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" />} />
</> </>
); );

View File

@@ -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 />;
} }

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -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 };