Files
paseo/packages/app/src/plugins/evaluate.ts
T
Mohamed Boudra 4fe006df86 feat(plugins): unify panel props and persist tab state (#7685)
* feat(plugins): unify panel props and persist tab state

* refactor(plugins): make agent panel registration canonical

* docs(plugins): update Chinese panel API reference and migration
2026-10-23 13:26:19 +07:00

592 lines
23 KiB
TypeScript

import { requirePluginText } from "@getpaseo/plugin/client/host";
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 PluginCleanup } from "@getpaseo/plugin";
import {
type PluginClientContext,
type PluginAgentPanelContribution,
type PluginWorkspacePanelContribution as WorkspacePanelInput,
type PluginScreenProps,
type PluginScreenTitle,
type PluginText,
type PluginSurfaceProps,
type PluginTimelineRendererContribution,
type PluginTimelineTransformerContribution,
type PluginButtonRegistration,
} from "@getpaseo/plugin/client";
import type {
EvaluatedPlugin,
PluginSidebarSection,
PluginAttachmentSourceContribution,
PluginThemeContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginSidebarContribution,
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
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",
"notification",
"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 normalizePluginText(text: import("@getpaseo/plugin/client").PluginText) {
return typeof text === "string" ? text.trim() : requirePluginText(text);
}
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<PluginText> {
if (typeof title === "function") return title as PluginScreenTitle<PluginText>;
try {
return requirePluginText(title);
} catch {}
throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`);
}
export type PluginClientRuntime = Pick<
PluginClientContext,
| "i18n"
| "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: [],
filePanels: [],
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 filePanelIds = new Set<string>();
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<PluginText>,
): PluginButtonRegistration<PluginText> {
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 = normalizePluginText(contribution.title);
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),
);
}
function registerPanel(contribution: PluginWorkspacePanelContribution): PluginCleanup {
const normalizedId = requireId(contribution.id, "workspace panel id");
if (workspacePanelIds.has(normalizedId)) {
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
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),
);
}
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 (!normalizePluginText(contribution.title))
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: normalizePluginText(contribution.title),
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));
},
addFilePanel(contribution) {
const panelId = requireId(contribution.id, "file panel id");
if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`);
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`File panel ${panelId} has no title`);
const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon);
const files = contribution.files;
const validEntries = (entries: unknown, extension: boolean): entries is string[] =>
Array.isArray(entries) &&
entries.every(
(entry) =>
typeof entry === "string" &&
entry.trim() === entry &&
entry.length > 0 &&
!/[\\/*?]/.test(entry) &&
(!extension || !entry.includes(".")),
);
if (
!files ||
Object.keys(files).some((key) => key !== "extensions" && key !== "filenames") ||
!validEntries(files.extensions, true) ||
(files.filenames !== undefined && !validEntries(files.filenames, false)) ||
(files.extensions.length === 0 && !files.filenames?.length)
) {
throw new Error(`File panel ${panelId} has invalid selector`);
}
if (typeof contribution.Component !== "function")
throw new Error(`File panel ${panelId} is not a component`);
filePanelIds.add(panelId);
return register(
collector.filePanels,
{
...contribution,
id: panelId,
title,
icon,
files: {
extensions: [...files.extensions],
...(files.filenames ? { filenames: [...files.filenames] } : {}),
},
},
() => filePanelIds.delete(panelId),
);
},
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>) {
return registerPanel({ ...contribution, context: "agent" });
},
addWorkspacePanel(input: WorkspacePanelInput<PluginText>) {
// COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23
if (input.context === "agent") return pluginContext.addAgentPanel(input);
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
if (input.context !== undefined && input.context !== "workspace") {
throw new Error(`Workspace panel ${input.id} has invalid context`);
}
return registerPanel({ ...input, context: "workspace" });
},
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 = normalizePluginText(contribution.title);
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 = normalizePluginText(contribution.description);
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: normalizePluginText(contribution.argumentHint),
},
() => 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 = normalizePluginText(contribution.title);
const pickerTitle = normalizePluginText(contribution.pickerTitle);
const searchPlaceholder = normalizePluginText(contribution.searchPlaceholder);
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 => {
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
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 === "@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,
filePanels: collector.filePanels,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSlashCommands: collector.clientSlashCommands,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
timelineRenderers: collector.timelineRenderers,
};
}