diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts index 4479d4a5..9c18f610 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts @@ -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", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index c7c76109..b3348a8c 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -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", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx new file mode 100644 index 00000000..01cad1be --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx @@ -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(null); + const frameRef = useRef(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 ( +
+
+ ); +} diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index b8fc914c..53bf0b9e 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -4,6 +4,7 @@ import { usePluginAction, usePluginData, usePluginStream, + usePluginToast, type PluginCommentAnnotationProps, type PluginCommentContextMenuItemProps, type PluginDetailTabProps, @@ -16,6 +17,7 @@ import { import { DEFAULT_CONFIG, JOB_KEYS, + PAGE_ROUTE, PLUGIN_ID, SAFE_COMMANDS, SLOT_IDS, @@ -23,12 +25,34 @@ import { TOOL_NAMES, WEBHOOK_KEYS, } from "../constants.js"; +import { AsciiArtAnimation } from "./AsciiArtAnimation.js"; -type CompanyRecord = { id: string; name: string; issuePrefix?: string | null }; +type CompanyRecord = { id: string; name: string; issuePrefix?: string | null; status?: string | null }; type ProjectRecord = { id: string; name: string; status?: string; path?: string | null }; type IssueRecord = { id: string; title: string; status: string; projectId?: string | null }; type GoalRecord = { id: string; title: string; status: string }; type AgentRecord = { id: string; name: string; status: string }; +type HostIssueRecord = { + id: string; + title: string; + status: string; + priority?: string | null; + createdAt?: string; +}; +type HostHeartbeatRunRecord = { + id: string; + status: string; + invocationSource?: string | null; + triggerDetail?: string | null; + createdAt?: string; + startedAt?: string | null; + finishedAt?: string | null; + agentId?: string | null; +}; +type HostLiveRunRecord = HostHeartbeatRunRecord & { + agentName?: string | null; + issueId?: string | null; +}; type OverviewData = { pluginId: string; @@ -158,6 +182,31 @@ const primaryButtonStyle: CSSProperties = { borderColor: "var(--foreground)", }; +function toneButtonStyle(tone: "success" | "warn" | "info"): CSSProperties { + if (tone === "success") { + return { + ...buttonStyle, + background: "color-mix(in srgb, #16a34a 18%, transparent)", + borderColor: "color-mix(in srgb, #16a34a 60%, var(--border))", + color: "#86efac", + }; + } + if (tone === "warn") { + return { + ...buttonStyle, + background: "color-mix(in srgb, #d97706 18%, transparent)", + borderColor: "color-mix(in srgb, #d97706 60%, var(--border))", + color: "#fcd34d", + }; + } + return { + ...buttonStyle, + background: "color-mix(in srgb, #2563eb 18%, transparent)", + borderColor: "color-mix(in srgb, #2563eb 60%, var(--border))", + color: "#93c5fd", + }; +} + const inputStyle: CSSProperties = { width: "100%", border: "1px solid var(--border)", @@ -204,6 +253,30 @@ function hostPath(companyPrefix: string | null | undefined, suffix: string): str return companyPrefix ? `/${companyPrefix}${suffix}` : suffix; } +function pluginPagePath(companyPrefix: string | null | undefined): string { + return hostPath(companyPrefix, `/${PAGE_ROUTE}`); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getObjectString(value: unknown, key: string): string | null { + if (!value || typeof value !== "object") return null; + const next = (value as Record)[key]; + return typeof next === "string" ? next : null; +} + +function getObjectNumber(value: unknown, key: string): number | null { + if (!value || typeof value !== "object") return null; + const next = (value as Record)[key]; + return typeof next === "number" && Number.isFinite(next) ? next : null; +} + +function isKitchenSinkDemoCompany(company: CompanyRecord): boolean { + return company.name.startsWith("Kitchen Sink Demo"); +} + function JsonBlock({ value }: { value: unknown }) { return
{JSON.stringify(value, null, 2)}
; } @@ -294,6 +367,41 @@ function StatusLine({ label, value }: { label: string; value: ReactNode }) { ); } +function PaginatedDomainCard({ + title, + items, + totalCount, + empty, + onLoadMore, + render, +}: { + title: string; + items: unknown[]; + totalCount: number | null; + empty: string; + onLoadMore: () => void; + render: (item: unknown, index: number) => ReactNode; +}) { + const hasMore = totalCount !== null ? items.length < totalCount : false; + + return ( +
+
+ {title} + {totalCount !== null ? {items.length} / {totalCount} : null} +
+ + {hasMore ? ( +
+ +
+ ) : null} +
+ ); +} + function usePluginOverview(companyId: string | null) { return usePluginData("overview", companyId ? { companyId } : {}); } @@ -392,6 +500,9 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp {label} {resolvedEntityType ? : null} +
+ This surface demo shows the host context for the current mount point. The metric button records a demo counter so you can verify plugin metrics wiring from a contextual surface. +
{entityQuery.data ? : null} @@ -410,6 +521,7 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) { const overview = usePluginOverview(context.companyId); + const toast = usePluginToast(); const emitDemoEvent = usePluginAction("emit-demo-event"); const startProgressStream = usePluginAction("start-progress-stream"); const writeMetric = usePluginAction("write-metric"); @@ -417,8 +529,21 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context STREAM_CHANNELS.progress, { companyId: context.companyId ?? undefined }, ); + const [quickActionStatus, setQuickActionStatus] = useState<{ + title: string; + body: string; + tone: "info" | "success" | "warn" | "error"; + } | null>(null); - const companyPath = hostPath(context.companyPrefix, `/plugins/${PLUGIN_ID}`); + useEffect(() => { + const latest = progressStream.events.at(-1); + if (!latest) return; + setQuickActionStatus({ + title: "Progress stream update", + body: latest.message ?? `Step ${latest.step ?? "?"}`, + tone: "info", + }); + }, [progressStream.events]); return (
@@ -432,13 +557,82 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context +
+ + + +
-
- Recent progress events: {progressStream.events.length} +
+
+ Recent progress events: {progressStream.events.length} +
+ {quickActionStatus ? ( +
+
{quickActionStatus.title}
+
{quickActionStatus.body}
+
+ ) : null} + {progressStream.events.length > 0 ? ( + + ) : null}
@@ -502,14 +772,538 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context /> - -
- The sidebar entry opens this page directly. Use it as the main kitchen-sink control surface. +
+ ); +} + +function KitchenSinkIssueCrudDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const [issues, setIssues] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [createTitle, setCreateTitle] = useState("Kitchen Sink demo issue"); + const [createDescription, setCreateDescription] = useState("Created from the Kitchen Sink embedded page."); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadIssues() { + if (!context.companyId) return; + setLoading(true); + try { + const result = await hostFetchJson(`/api/companies/${context.companyId}/issues`); + const nextIssues = result.slice(0, 8); + setIssues(nextIssues); + setDrafts( + Object.fromEntries( + nextIssues.map((issue) => [issue.id, { title: issue.title, status: issue.status }]), + ), + ); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadIssues(); + }, [context.companyId]); + + async function handleCreate() { + if (!context.companyId || !createTitle.trim()) return; + try { + await hostFetchJson(`/api/companies/${context.companyId}/issues`, { + method: "POST", + body: JSON.stringify({ + title: createTitle.trim(), + description: createDescription.trim() || undefined, + status: "todo", + priority: "medium", + }), + }); + toast({ title: "Issue created", body: createTitle.trim(), tone: "success" }); + setCreateTitle("Kitchen Sink demo issue"); + setCreateDescription("Created from the Kitchen Sink embedded page."); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue create failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleSave(issueId: string) { + const draft = drafts[issueId]; + if (!draft) return; + try { + await hostFetchJson(`/api/issues/${issueId}`, { + method: "PATCH", + body: JSON.stringify({ + title: draft.title.trim(), + status: draft.status, + }), + }); + toast({ title: "Issue updated", body: draft.title.trim(), tone: "success" }); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue update failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleDelete(issueId: string) { + try { + await hostFetchJson(`/api/issues/${issueId}`, { method: "DELETE" }); + toast({ title: "Issue deleted", tone: "info" }); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue delete failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + return ( +
+
+ This is a regular embedded React page inside Paperclip calling the board API directly. It creates, updates, and deletes issues for the current company. +
+ {!context.companyId ? ( +
Select a company to use issue demos.
+ ) : ( + <> +
+ setCreateTitle(event.target.value)} placeholder="Issue title" /> + setCreateDescription(event.target.value)} placeholder="Issue description" /> + +
+ {loading ?
Loading issues…
: null} + {error ?
{error}
: null} +
+ {issues.map((issue) => { + const draft = drafts[issue.id] ?? { title: issue.title, status: issue.status }; + return ( +
+
+ + setDrafts((current) => ({ + ...current, + [issue.id]: { ...draft, title: event.target.value }, + }))} + /> + + + +
+
+ ); + })} + {!loading && issues.length === 0 ?
No issues yet for this company.
: null} +
+ + )} +
+ ); +} + +function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const [companies, setCompanies] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [newCompanyName, setNewCompanyName] = useState(`Kitchen Sink Demo ${new Date().toLocaleTimeString()}`); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadCompanies() { + setLoading(true); + try { + const result = await hostFetchJson>("/api/companies"); + setCompanies(result); + setDrafts( + Object.fromEntries( + result.map((company) => [company.id, { name: company.name, status: company.status ?? "active" }]), + ), + ); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadCompanies(); + }, []); + + async function handleCreate() { + const trimmed = newCompanyName.trim(); + if (!trimmed) return; + const name = trimmed.startsWith("Kitchen Sink Demo") ? trimmed : `Kitchen Sink Demo ${trimmed}`; + try { + await hostFetchJson("/api/companies", { + method: "POST", + body: JSON.stringify({ + name, + description: "Created from the Kitchen Sink example plugin page.", + }), + }); + toast({ title: "Demo company created", body: name, tone: "success" }); + setNewCompanyName(`Kitchen Sink Demo ${Date.now()}`); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company create failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleSave(companyId: string) { + const draft = drafts[companyId]; + if (!draft) return; + try { + await hostFetchJson(`/api/companies/${companyId}`, { + method: "PATCH", + body: JSON.stringify({ + name: draft.name.trim(), + status: draft.status, + }), + }); + toast({ title: "Company updated", body: draft.name.trim(), tone: "success" }); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company update failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleDelete(company: CompanyRecord) { + try { + await hostFetchJson(`/api/companies/${company.id}`, { method: "DELETE" }); + toast({ title: "Demo company deleted", body: company.name, tone: "info" }); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company delete failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + const currentCompany = companies.find((company) => company.id === context.companyId) ?? null; + const demoCompanies = companies.filter(isKitchenSinkDemoCompany); + + return ( +
+
+ The worker SDK currently exposes company reads. This page shows a pragmatic embedded-app pattern for broader board actions by calling the host REST API directly. +
+
+
+ Current Company + {currentCompany ? : null}
- - {companyPath} - - +
{currentCompany?.name ?? "No current company selected"}
+
+
+ setNewCompanyName(event.target.value)} + placeholder="Kitchen Sink Demo Company" + /> + +
+ {loading ?
Loading companies…
: null} + {error ?
{error}
: null} +
+ {demoCompanies.map((company) => { + const draft = drafts[company.id] ?? { name: company.name, status: "active" }; + const isCurrent = company.id === context.companyId; + return ( +
+
+ + setDrafts((current) => ({ + ...current, + [company.id]: { ...draft, name: event.target.value }, + }))} + /> + + + +
+ {isCurrent ?
Current company cannot be deleted from this demo.
: null} +
+ ); + })} + {!loading && demoCompanies.length === 0 ? ( +
No demo companies yet. Create one above and manage it from this page.
+ ) : null} +
+
+ ); +} + +function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) { + return ( +
+
+
+ Plugins can host their own React page and behave like a native company page. Kitchen Sink now uses this route as a practical demo app, then keeps the lower-level worker console below for the rest of the SDK surface. +
+
+
+
+
+ The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage. +
+ + {pluginPagePath(context.companyPrefix)} + +
+
+
+ This is the same Paperclip ASCII treatment used in onboarding, copied into the example plugin so the package stays self-contained. +
+ +
+
+
+ ); +} + +function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const stateKey = "revenue_clicker"; + const revenueState = usePluginData( + "state-value", + context.companyId + ? { scopeKind: "company", scopeId: context.companyId, stateKey } + : {}, + ); + const writeScopedState = usePluginAction("write-scoped-state"); + const deleteScopedState = usePluginAction("delete-scoped-state"); + + const currentValue = useMemo(() => { + const raw = revenueState.data?.value; + if (typeof raw === "number") return raw; + const parsed = Number(raw ?? 0); + return Number.isFinite(parsed) ? parsed : 0; + }, [revenueState.data?.value]); + + async function adjust(delta: number) { + if (!context.companyId) return; + try { + await writeScopedState({ + scopeKind: "company", + scopeId: context.companyId, + stateKey, + value: currentValue + delta, + }); + revenueState.refresh(); + } catch (nextError) { + toast({ title: "Storage write failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function reset() { + if (!context.companyId) return; + try { + await deleteScopedState({ + scopeKind: "company", + scopeId: context.companyId, + stateKey, + }); + toast({ title: "Revenue counter reset", tone: "info" }); + revenueState.refresh(); + } catch (nextError) { + toast({ title: "Storage reset failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + return ( +
+
+ This clicker persists into plugin-scoped company storage. A real revenue plugin could store counters, sync cursors, or cached external IDs the same way. +
+ {!context.companyId ? ( +
Select a company to use company-scoped plugin storage.
+ ) : ( + <> +
+
{currentValue}
+
Stored at `company/{context.companyId}/{stateKey}`
+
+
+ {[-10, -1, 1, 10].map((delta) => ( + + ))} + +
+ + + )} +
+ ); +} + +function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) { + const [liveRuns, setLiveRuns] = useState([]); + const [recentRuns, setRecentRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadRuns() { + if (!context.companyId) return; + setLoading(true); + try { + const [nextLiveRuns, nextRecentRuns] = await Promise.all([ + hostFetchJson(`/api/companies/${context.companyId}/live-runs?minCount=5`), + hostFetchJson(`/api/companies/${context.companyId}/heartbeat-runs?limit=5`), + ]); + setLiveRuns(nextLiveRuns); + setRecentRuns(nextRecentRuns); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadRuns(); + }, [context.companyId]); + + return ( +
+
+ Plugin pages can feel like native Paperclip pages. This section demonstrates host toasts, company-scoped routing, and reading live heartbeat data from the embedded page. +
+
+
+ Company Route + +
+
+ This page is mounted as a real company route instead of living only under `/plugins/:pluginId`. +
+
+ {!context.companyId ? ( +
Select a company to read run data.
+ ) : ( +
+
+
+ Live Runs + +
+ {loading ?
Loading run data…
: null} + {error ?
{error}
: null} + { + const run = item as HostLiveRunRecord; + return ( +
+
+ {run.status} + {run.agentName ? : null} +
+
{run.id}
+ {run.agentId ? ( + + Open run + + ) : null} +
+ ); + }} + /> +
+
+ Recent Heartbeats + { + const run = item as HostHeartbeatRunRecord; + return ( +
+
+ {run.status} + {run.invocationSource ? : null} +
+
{run.id}
+
+ ); + }} + /> +
+
+ )} +
+ ); +} + +function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) { + return ( +
+ + + + +
); } @@ -517,10 +1311,14 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) { const companyId = context.companyId; const overview = usePluginOverview(companyId); - const companies = usePluginData("companies"); - const projects = usePluginData("projects", companyId ? { companyId } : {}); - const issues = usePluginData("issues", companyId ? { companyId } : {}); - const goals = usePluginData("goals", companyId ? { companyId } : {}); + const [companiesLimit, setCompaniesLimit] = useState(20); + const [projectsLimit, setProjectsLimit] = useState(20); + const [issuesLimit, setIssuesLimit] = useState(20); + const [goalsLimit, setGoalsLimit] = useState(20); + const companies = usePluginData("companies", { limit: companiesLimit }); + const projects = usePluginData("projects", companyId ? { companyId, limit: projectsLimit } : {}); + const issues = usePluginData("issues", companyId ? { companyId, limit: issuesLimit } : {}); + const goals = usePluginData("goals", companyId ? { companyId, limit: goalsLimit } : {}); const agents = usePluginData("agents", companyId ? { companyId } : {}); const [issueTitle, setIssueTitle] = useState("Kitchen Sink demo issue"); @@ -600,6 +1398,12 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null; const resumeAgent = usePluginAction("resume-agent"); const askAgent = usePluginAction("ask-agent"); + useEffect(() => { + setProjectsLimit(20); + setIssuesLimit(20); + setGoalsLimit(20); + }, [companyId]); + useEffect(() => { if (!selectedProjectId && projects.data?.[0]?.id) setSelectedProjectId(projects.data[0].id); }, [projects.data, selectedProjectId]); @@ -731,7 +1535,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
- Open plugin page + Open plugin page {projectRef ? (
-
- Companies - { - const company = item as CompanyRecord; - return
{company.name} ({company.id.slice(0, 8)})
; - }} - /> -
-
- Projects - { - const project = item as ProjectRecord; - return
{project.name} ({project.status ?? "unknown"})
; - }} - /> -
-
- Issues - { - const issue = item as IssueRecord; - return
{issue.title} ({issue.status})
; - }} - /> -
-
- Goals - { - const goal = item as GoalRecord; - return
{goal.title} ({goal.status})
; - }} - /> -
+ setCompaniesLimit((current) => current + 20)} + render={(item) => { + const company = item as CompanyRecord; + return
{company.name} ({company.id.slice(0, 8)})
; + }} + /> + setProjectsLimit((current) => current + 20)} + render={(item) => { + const project = item as ProjectRecord; + return
{project.name} ({project.status ?? "unknown"})
; + }} + /> + setIssuesLimit((current) => current + 20)} + render={(item) => { + const issue = item as IssueRecord; + return
{issue.title} ({issue.status})
; + }} + /> + setGoalsLimit((current) => current + 20)} + render={(item) => { + const goal = item as GoalRecord; + return
{goal.title} ({goal.status})
; + }} + />
@@ -1272,12 +2076,8 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null; export function KitchenSinkPage({ context }: PluginPageProps) { return (
-
-
- This page is the primary demo console for the Kitchen Sink example plugin. It is intentionally broad and exposes the current Paperclip plugin surface area in one place. -
-
+
); @@ -1437,7 +2237,7 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
Issues: {overview.data?.counts.issues ?? 0}
- Open page + Open page
); } diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts index fd0e9eff..37c8b861 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts @@ -225,12 +225,18 @@ function getCurrentCompanyId(params: Record): string { return companyId; } -async function listIssuesForCompany(ctx: PluginContext, companyId: string): Promise { - return await ctx.issues.list({ companyId, limit: 20, offset: 0 }); +function getListLimit(params: Record, 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 { - return await ctx.goals.list({ companyId, limit: 20, offset: 0 }); +async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise { + return await ctx.issues.list({ companyId, limit, offset: 0 }); +} + +async function listGoalsForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise { + return await ctx.goals.list({ companyId, limit, offset: 0 }); } function recentRecordsSnapshot(): DemoRecord[] { @@ -249,11 +255,11 @@ async function registerDataHandlers(ctx: PluginContext): Promise { 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 { }; }); - 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) => { diff --git a/packages/plugins/sdk/src/ui/hooks.ts b/packages/plugins/sdk/src/ui/hooks.ts index fdba2fe3..7710e80f 100644 --- a/packages/plugins/sdk/src/ui/hooks.ts +++ b/packages/plugins/sdk/src/ui/hooks.ts @@ -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( >("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(); +} diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index 05fdad63..f6daf328 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -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 diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 267b10b5..b1eddea5 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -300,6 +300,29 @@ export interface PluginDataResult { 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 // --------------------------------------------------------------------------- diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9aa3a002..253bfaae 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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 diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 1db68a07..5117b501 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -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. diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 149c686a..92438fb9 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -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; diff --git a/server/src/services/plugin-dev-watcher.ts b/server/src/services/plugin-dev-watcher.ts index 156b2368..50080ade 100644 --- a/server/src/services/plugin-dev-watcher.ts +++ b/server/src/services/plugin-dev-watcher.ts @@ -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 }, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 194a6116..bfd6f6cb 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -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 = (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); diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index bd8067c8..82494bf0 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -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 { + 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) { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a05bbcec..b8d77f44 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -155,6 +155,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> ); diff --git a/ui/src/pages/PluginPage.tsx b/ui/src/pages/PluginPage.tsx index 345579f7..dde96f35 100644 --- a/ui/src/pages/PluginPage.tsx +++ b/ui/src/pages/PluginPage.tsx @@ -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
Loading…
; } + if (!pluginId && pluginRoutePath) { + const duplicateMatches = contributions.filter((contribution) => + contribution.slots.some((slot) => slot.type === "page" && slot.routePath === pluginRoutePath), + ); + if (duplicateMatches.length > 1) { + return ( +
+ Multiple plugins declare the route {pluginRoutePath}. Use the plugin-id route until the conflict is resolved. +
+ ); + } + } + 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 ; } diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts index 6b0eb2be..d5d5b21b 100644 --- a/ui/src/plugins/bridge-init.ts +++ b/ui/src/plugins/bridge-init.ts @@ -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 diff --git a/ui/src/plugins/bridge.ts b/ui/src/plugins/bridge.ts index f5f1b30e..846df45b 100644 --- a/ui/src/plugins/bridge.ts +++ b/ui/src/plugins/bridge.ts @@ -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 { 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 { + events: T[]; + lastEvent: T | null; + connecting: boolean; + connected: boolean; + error: Error | null; + close(): void; +} + +export function usePluginStream( + channel: string, + options?: { companyId?: string }, +): PluginStreamResult { + const { pluginId, hostContext } = usePluginBridgeContext(); + const effectiveCompanyId = options?.companyId ?? hostContext.companyId ?? undefined; + const [events, setEvents] = useState([]); + const [lastEvent, setLastEvent] = useState(null); + const [connecting, setConnecting] = useState(Boolean(effectiveCompanyId)); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + const sourceRef = useRef(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 }; +} diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index d0e05515..11559414 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -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 };