feat(plugin): add host-rendered Icon component (#3903)

This commit is contained in:
Omer Cohen
2026-08-27 10:50:20 +00:00
committed by GitHub
parent 3377997f30
commit 8e0af11b66
12 changed files with 121 additions and 21 deletions
+7 -5
View File
@@ -121,14 +121,16 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
`layout.compact`. See `public-docs/plugins/reference.md`.
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | hooks and UI types |
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
+22
View File
@@ -333,6 +333,28 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function");
});
it("provides the host Icon component through @getpaseo/plugin", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon } = require("@getpaseo/plugin");
const module = { exports: {} };
module.exports.default = function(plugin) {
plugin.addSurface("main", function Surface() {
return Icon({ name: "Settings", size: 18, color: "#123456" });
});
return function() {};
};
return module.exports;
})`,
);
const Component = plugin.surfaces[0]?.Component;
expect(Component).toBeTypeOf("function");
const element = (Component as (props: never) => { props: unknown })({} as never);
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
});
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
+2 -1
View File
@@ -24,7 +24,7 @@ import {
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
import { Icon, resolvePluginIcon } from "./icons";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -247,6 +247,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
return {
defineAttachmentSource,
defineRpc,
Icon,
usePaseo,
useAgent,
useWorkspace,
+18
View File
@@ -0,0 +1,18 @@
import { Settings } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { Icon } from "./icons";
describe("Icon", () => {
it("renders a host Lucide icon with the requested presentation", () => {
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders nothing for an unknown icon name", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
});
});
+16 -3
View File
@@ -1,11 +1,24 @@
import { createElement, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
import type { PluginIconProps } from "@getpaseo/plugin";
import type { LucideIcon } from "lucide-react-native";
export function resolvePluginIcon(name: string): LucideIcon {
function findPluginIcon(name: string): LucideIcon | null {
const candidate = Reflect.get(LucideIcons, name);
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
return candidate as LucideIcon;
return isComponent ? (candidate as LucideIcon) : null;
}
export function resolvePluginIcon(name: string): LucideIcon {
const icon = findPluginIcon(name);
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
return icon;
}
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
const icon = findPluginIcon(name);
return icon ? createElement(icon, { size, color }) : null;
}
@@ -79,6 +79,7 @@ export async function inspectConfig(
`import React from "react";
import { Text } from "react-native";
import {
Icon,
type PluginAgentPanelProps,
useAgent,
usePaseo,
@@ -91,7 +92,7 @@ export function Surface() {
source: { kind: "directory", path: "/repo" },
});
void createWorkspace;
return <Text>Paseo API</Text>;
return <><Icon name="Settings" size={18} color="#123456" /><Text>Paseo API</Text></>;
}
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
@@ -103,6 +103,12 @@ declare module "@getpaseo/plugin" {
export interface PluginSurfaceProps extends PluginHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
@@ -261,6 +267,8 @@ declare module "@getpaseo/plugin" {
export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export const Icon: ComponentType<PluginIconProps>;
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
+6
View File
@@ -34,6 +34,12 @@ export interface PluginHostProps {
export interface PluginSurfaceProps extends PluginHostProps {}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
+6
View File
@@ -1,3 +1,6 @@
import type { ComponentType } from "react";
import type { PluginIconProps } from "./contracts.js";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
@@ -7,6 +10,8 @@ export {
type PluginAttachmentSearchPayload,
type PluginRpcContract,
} from "./server.js";
export declare const Icon: ComponentType<PluginIconProps>;
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
@@ -21,6 +26,7 @@ export type {
PluginHandlerContext,
PluginHostProps,
PluginOpenPanelOptions,
PluginIconProps,
PluginPanelLocation,
PluginTheme,
PluginSidebarContribution,
@@ -83,7 +83,7 @@ describe("plugin author module externals", () => {
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`import type { PluginContext } from "${sdk}";
`import { Icon, type PluginContext } from "${sdk}";
import { defineRpc } from "${sdk}/server";
import { z } from "zod";
@@ -93,8 +93,13 @@ const ping = defineRpc({
output: z.object({ ok: z.boolean() }),
});
function Surface() {
return Icon({ name: "Settings", size: 18 });
}
export default function contribute(plugin: PluginContext) {
plugin.handle(ping, async () => ({ ok: true }));
plugin.addSurface("main", Surface);
return () => undefined;
}
`,
@@ -103,6 +108,8 @@ export default function contribute(plugin: PluginContext) {
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain(`${sdk}/server`);
expect(serverBundle).toContain(`${sdk}/server`);
expect(clientBundle).toContain("Settings");
expect(serverBundle).not.toContain("Settings");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
expect(serverBundle).not.toContain("Invalid plugin RPC method");
},
@@ -62,7 +62,13 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
const pluginAuthorRuntime = {
defineAttachmentSource,
defineRpc,
Icon() {
throw new Error("Icon is available only in plugin client code");
},
};
function runtimeRequire(name: string): unknown {
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
+19 -9
View File
@@ -67,15 +67,15 @@ Paseo builds separate client and server bundles from `index.ts`. It rejects impo
Paseo provides these modules to client code:
| Module | Use it for |
| ------------------------- | ----------------------------------- |
| `@getpaseo/plugin` | Hooks and UI types |
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
| Module | Use it for |
| ------------------------- | --------------------------------------- |
| `@getpaseo/plugin` | Host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "<name>" is not available in plugin client code`.
@@ -166,6 +166,16 @@ export default function contribute(plugin: PluginContext) {
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.
Use the host-provided `Icon` component for Lucide icons inside client surfaces. It renders the icon from Paseo's installed Lucide version, so plugin bundles do not import `lucide-react-native` or `react-native-svg`. An unknown name renders nothing instead of failing the plugin surface.
```tsx
import { Icon } from "@getpaseo/plugin";
<Icon name="Settings" size={18} color={theme.colors.foreground} />;
```
Keep `Icon` in a `*.client.tsx` module.
## Timeline items
A plugin can replace a projected timeline entry with its own data and React Native renderer. Both