Files
paseo/packages/app/src/plugins/evaluate.ts
T
Mohamed Boudra b29bd98b5f feat(plugins): accept inline SVG and component icons
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 07:51:46 +02:00

522 lines
21 KiB
TypeScript

import type { createPluginHosts } from "./hosts";
import { openExternalUrl } from "@/utils/open-external-url";
import * as pluginUiRuntime from "./react-native/ui";
import { useSettings } from "./settings/use-settings";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginClientRuntime from "@getpaseo/plugin/client";
import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime";
// eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative.
import * as ReactNative from "react-native";
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
import * as ReactQuery from "@tanstack/react-query";
import * as Zod from "zod";
import {
type PluginAttachmentSourceContribution,
type PluginCleanup,
type PluginThemeContribution,
} from "@getpaseo/plugin";
import {
type PluginCommandCenterItemContribution,
type PluginClientContext,
type PluginClientSlashCommandContribution,
type PluginSidebarContribution,
type PluginScreenContribution,
type PluginScreenProps,
type PluginScreenTitle,
type PluginSidebarItemContribution,
type PluginSurfaceProps,
type PluginTimelineRendererContribution,
type PluginTimelineTransformerContribution,
type PluginWorkspacePanelContribution,
type PluginButtonRegistration,
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
const PANEL_LOCATIONS = ["workspace", "explorer"] as const;
const TIMELINE_ITEM_TYPES = new Set([
"user_message",
"assistant_message",
"reasoning",
"tool_call",
"todo",
"error",
"compaction",
]);
function normalizePanelLocations(
panelId: string,
locations: PluginWorkspacePanelContribution["locations"],
): readonly (typeof PANEL_LOCATIONS)[number][] {
if (locations === undefined) return ["workspace"];
if (!Array.isArray(locations) || locations.length === 0) {
throw new Error(`Workspace panel ${panelId} must support at least one location`);
}
const normalized = locations.map((location) => {
if (!PANEL_LOCATIONS.includes(location as never)) {
throw new Error(`Workspace panel ${panelId} has invalid location: ${String(location)}`);
}
return location;
});
if (new Set(normalized).size !== normalized.length) {
throw new Error(`Workspace panel ${panelId} has duplicate locations`);
}
return normalized;
}
function isComponentType(value: unknown): boolean {
return (
typeof value === "function" ||
(typeof value === "object" && value !== null && "$$typeof" in value)
);
}
function requireId(value: string, label: string): string {
const id = value.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
return id;
}
/** A non-empty string, or a function of the screen's params resolved when the header renders. */
function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle {
if (typeof title === "function") return title as PluginScreenTitle;
if (typeof title === "string" && title.trim()) return title.trim();
throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`);
}
export type PluginClientRuntime = Pick<
PluginClientContext,
| "playAudio"
| "paseo"
| "rpc"
| "openSettings"
| "openScreen"
| "openSurface"
| "openPanel"
| "addComposerPill"
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
export function runPluginClientBundle(
id: string,
bundle: string,
runtime: PluginClientRuntime,
onChange: () => void = () => undefined,
): EvaluatedPlugin {
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
settingsScreens: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
const surfaceIds = new Set<string>();
const settingsScreenIds = new Set<string>();
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
header: new Set(),
footer: new Set(),
};
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const clientSlashCommandNames = new Set<string>();
const attachmentSourceIds = new Set<string>();
const themeIds = new Set<string>();
const timelineTransformerIds = new Set<string>();
const timelineRendererIds = new Set<string>();
const removals = new Set<PluginCleanup>();
let setupComplete = false;
let stopped = false;
function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration {
let active = true;
const remove = () => {
if (!active) return;
active = false;
registration.remove();
removals.delete(remove);
};
removals.add(remove);
return {
update: (patch) => {
if (active && !stopped) registration.update(patch);
},
remove,
};
}
const notifyChange = () => {
if (setupComplete) onChange();
};
function register<T>(items: T[], item: T, release: () => void): PluginCleanup {
items.push(item);
notifyChange();
let active = true;
const remove = () => {
if (!active) return;
active = false;
const index = items.indexOf(item);
if (index !== -1) items.splice(index, 1);
release();
removals.delete(remove);
notifyChange();
};
removals.add(remove);
return remove;
}
function addSidebarItem(
section: PluginSidebarSection,
contribution: PluginSidebarItemContribution,
): PluginCleanup {
const ids = sidebarItemIds[section];
const normalizedId = requireId(contribution.id, `sidebar ${section} item id`);
if (ids.has(normalizedId))
throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`);
const title = contribution.title.trim();
if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`);
if (!isComponentType(contribution.Component)) {
throw new Error(`Sidebar item ${normalizedId} is not a component`);
}
ids.add(normalizedId);
return register(
collector.sidebarItems[section],
{ id: normalizedId, title, Component: contribution.Component },
() => ids.delete(normalizedId),
);
}
const pluginContext: PluginClientContext = {
...runtime,
addSettingsScreen(contribution) {
const screenId = requireId(contribution.id, "settings screen id");
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
settingsScreenIds.delete(screenId),
);
},
addScreen(contribution: PluginScreenContribution) {
if (typeof contribution !== "object" || contribution === null) {
throw new Error("addScreen takes { id, title, Component }");
}
const normalizedId = requireId(contribution.id, "screen id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate screen: ${normalizedId}`);
const title = requireScreenTitle(normalizedId, contribution.title);
if (!isComponentType(contribution.Component)) {
throw new Error(`Screen ${normalizedId} is not a component`);
}
surfaceIds.add(normalizedId);
return register(
collector.surfaces,
{ id: normalizedId, title, Component: contribution.Component },
() => surfaceIds.delete(normalizedId),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("footer", contribution);
},
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
if (!isComponentType(Component)) throw new Error(`Screen ${surfaceId} is not a component`);
return pluginContext.addScreen({
id: surfaceId,
title: surfaceId,
Component: (props: PluginScreenProps) => React.createElement(Component, props),
});
},
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
const legacy = {
id: normalizedId,
title: contribution.title.trim(),
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
surface: requireId(contribution.surface, "sidebar surface id"),
};
// Shares the header's ids: the sidebar orders both kinds of item under one key.
const ids = sidebarItemIds.header;
if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar header item: ${normalizedId}`);
if (!legacy.title) throw new Error(`Sidebar item ${normalizedId} has no title`);
ids.add(normalizedId);
return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId));
},
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
const normalizedId = requireId(contribution.id, "workspace panel id");
if (workspacePanelIds.has(normalizedId)) {
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
return register(
collector.workspacePanels,
{
...contribution,
id: normalizedId,
title,
icon,
locations,
},
() => workspacePanelIds.delete(normalizedId),
);
},
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
const normalizedId = requireId(contribution.id, "Command Center item id");
if (commandCenterItemIds.has(normalizedId)) {
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
contribution.context !== "agent"
) {
throw new Error(`Command Center item ${normalizedId} has invalid context`);
}
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
commandCenterItemIds.add(normalizedId);
return register(
collector.commandCenterItems,
{
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
},
() => commandCenterItemIds.delete(normalizedId),
);
},
addSlashCommand(contribution: PluginClientSlashCommandContribution) {
const name = requireId(contribution.name, "client slash command name");
if (clientSlashCommandNames.has(name)) {
throw new Error(`Duplicate client slash command: ${name}`);
}
const description = contribution.description.trim();
if (!description) throw new Error(`Client slash command ${name} has no description`);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Client slash command ${name} has invalid context`);
}
if (typeof contribution.onSubmit !== "function") {
throw new Error(`Client slash command ${name} has no callback`);
}
clientSlashCommandNames.add(name);
return register(
collector.clientSlashCommands,
{
...contribution,
name,
description,
argumentHint: contribution.argumentHint.trim(),
},
() => clientSlashCommandNames.delete(name),
);
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
const icon = parseSerializablePluginIcon(
`Attachment source ${normalizedId}`,
contribution.icon,
);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
attachmentSourceIds.add(normalizedId);
return register(
collector.attachmentSources,
{
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
},
() => attachmentSourceIds.delete(normalizedId),
);
},
addTheme(contribution: PluginThemeContribution) {
const normalizedId = requireId(contribution.id, "theme id");
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
themeIds.add(normalizedId);
return register(collector.themes, theme, () => themeIds.delete(normalizedId));
},
addTimelineTransformer(contribution: PluginTimelineTransformerContribution) {
const normalizedId = requireId(contribution.id, "timeline transformer id");
if (timelineTransformerIds.has(normalizedId)) {
throw new Error(`Duplicate timeline transformer: ${normalizedId}`);
}
if (!contribution.query || typeof contribution.query.itemType !== "string") {
throw new Error(`Timeline transformer ${normalizedId} has no item type`);
}
if (!TIMELINE_ITEM_TYPES.has(contribution.query.itemType)) {
throw new Error(
`Timeline transformer ${normalizedId} has invalid item type: ${contribution.query.itemType}`,
);
}
if (typeof contribution.transform !== "function") {
throw new Error(`Timeline transformer ${normalizedId} has no transform`);
}
if (contribution.id !== normalizedId) {
throw new Error(`Invalid timeline transformer id: ${contribution.id}`);
}
timelineTransformerIds.add(normalizedId);
return register(collector.timelineTransformers, contribution, () =>
timelineTransformerIds.delete(normalizedId),
);
},
addTimelineRenderer(contribution: PluginTimelineRendererContribution) {
const kind = requireId(contribution.kind, "timeline renderer kind");
if (!Number.isInteger(contribution.version) || contribution.version < 1) {
throw new Error(`Timeline renderer ${kind} has invalid version`);
}
const rendererId = `${kind}/${contribution.version}`;
if (timelineRendererIds.has(rendererId)) {
throw new Error(`Duplicate timeline renderer: ${rendererId}`);
}
if (!contribution.schema || typeof contribution.schema.safeParse !== "function") {
throw new Error(`Timeline renderer ${rendererId} has no schema`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Timeline renderer ${rendererId} is not a component`);
}
timelineRendererIds.add(rendererId);
return register(collector.timelineRenderers, { ...contribution, kind }, () =>
timelineRendererIds.delete(rendererId),
);
},
addComposerPill(contribution) {
if (stopped) throw new Error("Plugin has stopped");
return trackButton(runtime.addComposerPill(contribution));
},
addHeaderButton(contribution) {
if (stopped) throw new Error("Plugin has stopped");
return trackButton(runtime.addHeaderButton(contribution));
},
};
const runtimeRequire = (name: string): unknown => {
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@getpaseo/plugin") return pluginSharedRuntime;
if (name === "@getpaseo/plugin/client")
return {
...pluginClientRuntime,
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
useHosts: () =>
React.useSyncExternalStore(
runtime.hosts.subscribe,
runtime.hosts.getSnapshot,
runtime.hosts.getSnapshot,
),
};
if (name === "@getpaseo/plugin/client/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
};
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function")
throw new Error(`Plugin ${id} client bundle is not executable`);
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
let entryCleanup: PluginCleanup | undefined;
try {
entryCleanup = setup(pluginContext);
if (typeof entryCleanup !== "function")
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
for (const item of collector.legacySidebarItems) {
if (!surfaceIds.has(item.surface)) {
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
}
}
} catch (error) {
stopped = true;
try {
void Promise.resolve(
typeof entryCleanup === "function" ? entryCleanup() : entryCleanup,
).catch((cleanupError) => {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
});
} catch (cleanupError) {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
} finally {
for (const remove of removals) remove();
}
throw error;
}
setupComplete = true;
const cleanupEntry = entryCleanup;
const cleanup = async () => {
if (stopped) return;
stopped = true;
try {
await cleanupEntry();
} finally {
for (const remove of removals) remove();
}
};
return {
id,
cleanup,
surfaces: collector.surfaces,
settingsScreens: collector.settingsScreens,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSlashCommands: collector.clientSlashCommands,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
timelineRenderers: collector.timelineRenderers,
};
}