From 8e0af11b669e6f424297e1885b677e5854fdbfd9 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:50:20 +0300 Subject: [PATCH] feat(plugin): add host-rendered Icon component (#3903) --- docs/plugins.md | 12 ++++---- packages/app/src/plugins/evaluate.test.ts | 22 +++++++++++++++ packages/app/src/plugins/evaluate.ts | 3 +- packages/app/src/plugins/icons.test.ts | 18 ++++++++++++ packages/app/src/plugins/icons.ts | 19 +++++++++++-- .../cli/src/commands/plugin/scaffold.test.ts | 3 +- packages/cli/src/commands/plugin/scaffold.ts | 8 ++++++ packages/plugin/src/contracts.ts | 6 ++++ packages/plugin/src/index.ts | 6 ++++ .../src/server/plugins/compiler.test.ts | 9 +++++- .../src/server/plugins/plugin-process.ts | 8 +++++- public-docs/plugins/reference.md | 28 +++++++++++++------ 12 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 packages/app/src/plugins/icons.test.ts diff --git a/docs/plugins.md b/docs/plugins.md index 46ece9307a..3490b908c5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 238ab2e69f..a4975a2046 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index ed528f10aa..8e7a5e5f04 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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, diff --git a/packages/app/src/plugins/icons.test.ts b/packages/app/src/plugins/icons.test.ts new file mode 100644 index 0000000000..4a4969d6fb --- /dev/null +++ b/packages/app/src/plugins/icons.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index de36d962d6..1e3fdb4cbf 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -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; } diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 8655a1ca01..11ae66466d 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -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 Paseo API; + return <>Paseo API; } export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 643fad81db..ee15dbe12f 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -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; export type PluginContribution = (plugin: PluginContext) => PluginCleanup; + export const Icon: ComponentType; + export function useRpc( contract: PluginRpcContract, ): (input: ZodInput) => Promise>; diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 43b472a4af..1faf4924c4 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -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; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 9f7ed808ae..67dc1b512f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -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; export type { PluginAttachmentSourceContribution, PluginAgentCommandContext, @@ -21,6 +26,7 @@ export type { PluginHandlerContext, PluginHostProps, PluginOpenPanelOptions, + PluginIconProps, PluginPanelLocation, PluginTheme, PluginSidebarContribution, diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index df35eff59d..27c6121d3a 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -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"); }, diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 89c848f957..31eac46b15 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -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; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index a19c2e5ad3..f4ebca30e5 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 "" 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"; + +; +``` + +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