Let plugins use Paseo modals, icons, and toasts (#3967)

* feat(plugins): add React Native UI primitives

Give client plugins host-owned adaptive UI without exposing app internals. Preserve plugin runtime contexts when modal content moves through the compact portal host.

* docs(plugins): clarify host UI reference

* refactor(plugins): harden host UI boundary

Replace mocked pass-through coverage with a real browser plugin journey, keep host runtime ownership inside the evaluator, preserve toast context across compact portals, and expose modal content as a compound component.

* fix(plugins): defer native UI presentation runtime

Loading the plugin evaluator initialized the modal and toast presentation stacks even when no plugin requested host UI. Keep the capability stable while delaying platform-heavy presentation code until Modal.Content renders.

* refactor(plugins): move modal metadata to root

Keep Modal.Content body-only while the top-level Modal owns the host-rendered title and leading icon. This leaves presentation metadata stable as the compound API gains future regions.
This commit is contained in:
Mohamed Boudra
2026-08-28 11:43:30 +02:00
committed by GitHub
parent a1ed5a8bad
commit deb846eed6
26 changed files with 580 additions and 137 deletions
+10 -7
View File
@@ -121,17 +121,20 @@ 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 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.
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`.
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` | host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
@@ -0,0 +1,151 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function ModalBody({ onSaved }) {
usePaseo();
useQueryClient();
const toast = useToast();
function save() {
toast.show("Issue saved", { variant: "success" });
onSaved();
}
return <View>
<Text>Plugin modal contexts ready</Text>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
</View>;
}
function Surface() {
const [open, setOpen] = useState(false);
return <View>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row" }}>
<Icon name="Pencil" size={18} />
<Text>Open plugin modal</Text>
</View>
</Pressable>
<Modal
title="Edit plugin issue"
icon={<Icon name="Pencil" size={18} />}
open={open}
onOpenChange={setOpen}
>
<Modal.Content>
<ModalBody onSaved={() => setOpen(false)} />
</Modal.Content>
</Modal>
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({
id: "main",
title: "Host UI",
icon: "PanelsTopLeft",
surface: "main",
});
return () => {};
}`;
async function openHostUiPlugin(page: Page): Promise<void> {
await gotoAppShell(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function useNonCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 1100, height: 800 });
}
async function useCompactLayout(page: Page): Promise<void> {
await page.setViewportSize({ width: 390, height: 844 });
}
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Host UI", exact: true }).click();
}
async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(dialog).not.toBeVisible();
}
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
}
async function savePluginIssue(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save issue", exact: true }).click();
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
}
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await useNonCompactLayout(page);
await openHostUiPlugin(page);
await test.step("non-compact layouts use a centered dialog", async () => {
await openPluginModal(page);
await expectCenteredPluginDialog(page);
await closeCenteredPluginDialog(page);
});
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
await useCompactLayout(page);
await reopenHostUiPluginFromCompactSidebar(page);
await openPluginModal(page);
await expectCompactPluginSheet(page);
await savePluginIssue(page);
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -23,6 +23,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import {
@@ -450,6 +451,8 @@ export interface AdaptiveModalSheetProps {
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
contextBridge?: ContextBridge | null;
}
export function AdaptiveModalSheet({
@@ -467,6 +470,7 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
sizeContentToCurrentSnapPoint = false,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -634,7 +638,7 @@ export function AdaptiveModalSheet({
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
contextBridge={contextBridge}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
@@ -0,0 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ToastApi } from "@/components/toast-host";
const ToastContext = createContext<ToastApi | null>(null);
export function useToast(): ToastApi {
const value = useContext(ToastContext);
if (!value) {
throw new Error("useToast must be used within ToastProvider");
}
return value;
}
export function ToastApiProvider({ api, children }: { api: ToastApi; children: ReactNode }) {
return <ToastContext.Provider value={api}>{children}</ToastContext.Provider>;
}
+6 -13
View File
@@ -1,23 +1,16 @@
import { createContext, useContext, type ReactNode } from "react";
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
import type { ReactNode } from "react";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ToastApiProvider } from "./toast-api-context";
const ToastContext = createContext<ToastApi | null>(null);
export function useToast(): ToastApi {
const value = useContext(ToastContext);
if (!value) {
throw new Error("useToast must be used within ToastProvider");
}
return value;
}
export { ToastApiProvider, useToast } from "./toast-api-context";
export function ToastProvider({ children }: { children: ReactNode }) {
const { api, toast, dismiss } = useToastHost();
return (
<ToastContext.Provider value={api}>
<ToastApiProvider api={api}>
{children}
<ToastViewport toast={toast} onDismiss={dismiss} />
</ToastContext.Provider>
</ToastApiProvider>
);
}
@@ -1,91 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeIsConnected: () => connection.connected,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => connection.supported,
}));
import { PluginCatalogSync } from "./catalog-sync";
import { pluginRegistry } from "./registry";
function bundle(): string {
return `(function() { return { default: function(plugin) {
plugin.addSurface("main", function() { return null; });
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
} }; })`;
}
const unsubscribe = vi.fn();
const catalogClient = {
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
on: vi.fn(() => unsubscribe),
} as unknown as DaemonClient;
describe("PluginCatalogSync", () => {
let container: HTMLElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
connection.connected = true;
connection.supported = true;
unsubscribe.mockReset();
vi.mocked(catalogClient.getPluginCatalog).mockClear();
vi.mocked(catalogClient.on).mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
pluginRegistry.removeHost("host-a");
container.remove();
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
vi.unstubAllGlobals();
});
async function renderSync(): Promise<void> {
await act(async () => {
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
await Promise.resolve();
});
}
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
await renderSync();
const first = pluginRegistry.getSnapshot()[0];
expect(first?.id).toBe("example");
first?.queryClient.setQueryData(["owned"], "state");
connection.connected = false;
await renderSync();
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
expect(pluginRegistry.getSnapshot()).toEqual([]);
connection.connected = true;
await renderSync();
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
act(() => root.unmount());
root = createRoot(container);
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
expect(pluginRegistry.getSnapshot()).toEqual([]);
expect(unsubscribe).toHaveBeenCalledTimes(2);
});
});
+20
View File
@@ -377,6 +377,26 @@ describe("evaluatePluginClientBundle", () => {
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
});
it("provides Paseo UI through @getpaseo/plugin/react-native", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native");
const module = { exports: {} };
module.exports.default = function(plugin) {
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
throw new Error("React Native plugin UI is incomplete");
}
plugin.addSurface("main", function Surface() { return null; });
return function() {};
};
return module.exports;
})`,
);
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
+4
View File
@@ -26,6 +26,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { Icon, resolvePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -263,6 +264,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
useRpc,
};
}
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@getpaseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
}
@@ -0,0 +1,61 @@
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native";
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host";
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
interface ModalContextValue {
open: boolean;
dismiss(): void;
header: {
title: string;
leading?: ReactNode;
};
}
const ModalContext = createContext<ModalContextValue | null>(null);
function ModalRoot({ title, icon, open, onOpenChange, children }: ModalProps) {
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const dismiss = useCallback(() => onOpenChangeRef.current(false), []);
const header = useMemo(() => ({ title, leading: icon }), [icon, title]);
const value = useMemo(() => ({ open, dismiss, header }), [dismiss, header, open]);
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
}
function useModalContext(): ModalContextValue {
const context = useContext(ModalContext);
if (!context) throw new Error("Modal.Content must be rendered inside Modal");
return context;
}
function ModalContent({ children }: ModalContentProps) {
const modal = useModalContext();
const queryClient = useQueryClient();
const toast = useAppToast();
const bridgePluginRuntime = usePluginRuntimeContextBridge();
const contextBridge = useCallback(
(content: ReactNode) => (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>{bridgePluginRuntime(content)}</ToastApiProvider>
</QueryClientProvider>
),
[bridgePluginRuntime, queryClient, toast],
);
const { AdaptiveModalSheet } =
require("../../components/adaptive-modal-sheet") as typeof import("../../components/adaptive-modal-sheet");
return (
<AdaptiveModalSheet
visible={modal.open}
onClose={modal.dismiss}
header={modal.header}
contextBridge={contextBridge}
>
{children}
</AdaptiveModalSheet>
);
}
export const Modal: ModalComponent = Object.assign(ModalRoot, { Content: ModalContent });
@@ -0,0 +1,5 @@
import { Icon } from "../icons";
import { Modal } from "./modal";
import { useToast } from "./toast";
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
@@ -0,0 +1,18 @@
import { useMemo } from "react";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native";
import { useToast as useAppToast } from "@/contexts/toast-api-context";
export function useToast(): ToastApi {
const toast = useAppToast();
return useMemo(
() => ({
show(message: string, options?: ToastOptions) {
toast.show(message, options);
},
error(message: string) {
toast.error(message);
},
}),
[toast],
);
}
+4 -1
View File
@@ -31,7 +31,10 @@ class PluginRegistry {
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
options: { replacePluginId?: string; client?: DaemonClient } = {},
options: {
replacePluginId?: string;
client?: DaemonClient;
} = {},
): boolean {
const previous = this.byHost.get(serverId) ?? [];
const previousTimelineBundles = previous
@@ -78,8 +78,8 @@ export async function inspectConfig(
path.join(directory, "main.client.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import {
Icon,
type PluginAgentPanelProps,
type PluginClientContext,
type PluginComposerPillProps,
@@ -91,11 +91,12 @@ import { inspect } from "./inspect.shared";
export function Surface() {
const paseo = usePaseo();
const toast = useToast();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
});
void createWorkspace;
return <><Icon name="Settings" size={18} color="#123456" /><Text>Paseo API</Text></>;
return <><Icon name="Settings" size={18} color="#123456" /><Text onPress={() => toast.show("Ready")}>Paseo API</Text><Modal title="Example" icon={<Icon name="Settings" />} open={false} onOpenChange={() => {}}><Modal.Content><Text>Modal</Text></Modal.Content></Modal></>;
}
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
@@ -56,6 +56,46 @@ const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
declare module "@getpaseo/plugin/react-native" {
import type { ComponentType, FunctionComponent, ReactNode } from "react";
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
export interface ModalProps {
title: string;
icon?: ReactNode;
open: boolean;
onOpenChange(open: boolean): void;
children: ReactNode;
}
export interface ModalContentProps {
children: ReactNode;
}
export interface ModalComponent extends FunctionComponent<ModalProps> {
Content: ComponentType<ModalContentProps>;
}
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
export interface ToastOptions {
variant?: ToastVariant;
durationMs?: number;
}
export interface ToastApi {
show(message: string, options?: ToastOptions): void;
error(message: string): void;
}
export const Icon: ComponentType<PluginIconProps>;
export const Modal: ModalComponent;
export function useToast(): ToastApi;
}
declare module "@getpaseo/plugin" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
+5
View File
@@ -18,6 +18,11 @@
"source": "./src/server.ts",
"default": "./dist/server.js"
},
"./react-native": {
"types": "./dist/react-native.d.ts",
"source": "./src/react-native.ts",
"default": "./dist/react-native.js"
},
"./host": {
"types": "./dist/host.d.ts",
"source": "./src/host.ts",
+5 -1
View File
@@ -11,12 +11,16 @@ export interface PluginClientStateSource {
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
export function usePluginClientStateSource(): PluginClientStateSource | null {
return useContext(PluginClientStateContext);
}
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
const source = useContext(PluginClientStateContext);
const source = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
+4
View File
@@ -1,5 +1,9 @@
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
} from "./runtime-context-bridge.js";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
+5 -1
View File
@@ -3,12 +3,16 @@ import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PaseoApi | null>(null);
export function usePaseoContextValue(): PaseoApi | null {
return useContext(PaseoApiContext);
}
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PaseoApi {
const paseo = useContext(PaseoApiContext);
const paseo = usePaseoContextValue();
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ComponentType, FunctionComponent, ReactNode } from "react";
import type { PluginIconProps } from "./contracts.js";
export interface ModalProps {
title: string;
icon?: ReactNode;
open: boolean;
onOpenChange(open: boolean): void;
children: ReactNode;
}
export interface ModalContentProps {
children: ReactNode;
}
export interface ModalComponent extends FunctionComponent<ModalProps> {
Content: ComponentType<ModalContentProps>;
}
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
export interface ToastOptions {
variant?: ToastVariant;
durationMs?: number;
}
export interface ToastApi {
show(message: string, options?: ToastOptions): void;
error(message: string): void;
}
export declare const Icon: ComponentType<PluginIconProps>;
export declare const Modal: ModalComponent;
export declare function useToast(): ToastApi;
export type { PluginIconProps } from "./contracts.js";
+6 -2
View File
@@ -2,12 +2,16 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { callPluginRpc, type PluginRpcContract } from "./rpc.js";
interface PluginRpcContextValue {
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
export function usePluginRpcContextValue(): PluginRpcContextValue | null {
return useContext(PluginRpcContext);
}
export function PluginRpcProvider({
children,
invoke,
@@ -22,7 +26,7 @@ export function PluginRpcProvider({
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
const context = useContext(PluginRpcContext);
const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
@@ -0,0 +1,33 @@
import { useCallback, type ReactNode } from "react";
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode;
/** Rebuilds plugin runtime contexts inside React Native portal hosts. */
export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
const paseo = usePaseoContextValue();
const rpc = usePluginRpcContextValue();
const state = usePluginClientStateSource();
if (!paseo || !rpc) {
throw new Error("Plugin UI must run inside a contributed plugin surface");
}
return useCallback(
(children: ReactNode) => {
const content = state ? (
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
) : (
children
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>{content}</PluginRpcProvider>
</PaseoApiProvider>
);
},
[paseo, rpc, state],
);
}
@@ -83,7 +83,8 @@ describe("plugin author module externals", () => {
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`import { Icon, type PluginContext } from "${sdk}";
`import type { PluginContext } from "${sdk}";
import { Icon } from "${sdk}/react-native";
import { defineRpc } from "${sdk}/server";
import { z } from "zod";
@@ -106,8 +107,10 @@ export default function contribute(plugin: PluginContext) {
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain(`${sdk}/react-native`);
expect(clientBundle).toContain(`${sdk}/server`);
expect(serverBundle).toContain(`${sdk}/server`);
expect(serverBundle).not.toContain(`${sdk}/react-native`);
expect(clientBundle).toContain("Settings");
expect(serverBundle).not.toContain("Settings");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
+18 -2
View File
@@ -4,7 +4,10 @@ import { createRequire } from "node:module";
import path from "node:path";
import { parse } from "@babel/parser";
import type { Plugin } from "esbuild";
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
import {
PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url);
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
@@ -273,10 +276,23 @@ function makeHermesInteropEager(code: string): string {
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function exactSpecifierFilter(specifiers: readonly string[]): RegExp {
const alternatives = specifiers.map((specifier) =>
specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return new RegExp(`^(${alternatives.join("|")})$`);
}
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
const filter =
target === "server"
? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/
? exactSpecifierFilter([
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
])
: /^node:/;
return {
name: `paseo-plugin-${target}-unused-platform-modules`,
@@ -9,7 +9,7 @@ import {
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import { isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
@@ -71,6 +71,9 @@ const pluginAuthorRuntime = {
};
function runtimeRequire(name: string): unknown {
if (isPluginClientOnlySdkSpecifier(name)) {
throw new Error(`${name} is available only in plugin client code`);
}
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
return nodeRequire(name);
}
@@ -5,13 +5,23 @@
// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never
// published — that scope is not ours. Plugins scaffolded against that name still import it, so
// both spellings resolve. Remove the @paseo/* entries after 2026-11-19.
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/react-native",
"@paseo/plugin/react-native",
] as const;
export const PLUGIN_SDK_SPECIFIERS = [
"@getpaseo/plugin",
"@getpaseo/plugin/server",
"@paseo/plugin",
"@paseo/plugin/server",
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
] as const;
export function isPluginSdkSpecifier(name: string): boolean {
return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name);
}
export function isPluginClientOnlySdkSpecifier(name: string): boolean {
return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name);
}
+107 -14
View File
@@ -42,7 +42,9 @@ The required root manifest is `paseo-plugin.json`. It contains the default plugi
The entry point is `index.ts` at the plugin root. Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
The generated declaration file supplies `@getpaseo/plugin`, `@getpaseo/plugin/react-native`, and
`@getpaseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules.
Regenerate a fresh project with the matching CLI when the plugin contract changes.
Add runtime-specific files as the plugin grows:
@@ -67,15 +69,16 @@ 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` | 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 |
| Module | Use it for |
| ------------------------------- | ------------------------------------- |
| `@getpaseo/plugin` | Contribution contracts and data hooks |
| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks |
| `@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,15 +169,105 @@ 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.
## Host UI
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in `*.client.tsx` files. This example
opens a controlled modal, renders a host icon, and confirms the action with a toast:
```tsx
import { Icon } from "@getpaseo/plugin";
import type { PluginSurfaceProps } from "@getpaseo/plugin";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
<Icon name="Settings" size={18} color={theme.colors.foreground} />;
export function IssueActions({ theme }: PluginSurfaceProps) {
const [open, setOpen] = useState(false);
const toast = useToast();
function saveIssue() {
toast.show("Issue saved", { variant: "success" });
setOpen(false);
}
return (
<View>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Icon name="Pencil" size={18} color={theme.colors.foreground} />
<Text style={{ color: theme.colors.foreground }}>Edit issue</Text>
</View>
</Pressable>
<Modal
title="Edit issue"
icon={<Icon name="Pencil" size={18} color={theme.colors.foreground} />}
open={open}
onOpenChange={setOpen}
>
<Modal.Content>
<Pressable accessibilityRole="button" onPress={saveIssue}>
<Text style={{ color: theme.colors.foreground }}>Save</Text>
</Pressable>
</Modal.Content>
</Modal>
</View>
);
}
```
Keep `Icon` in a `*.client.tsx` module.
### Modal
`Modal` uses a bottom sheet on compact layouts and a centered dialog otherwise. The plugin owns
the `open` state.
| Prop | Type | Required | Behavior |
| -------------- | ------------------------- | -------- | -------------------------------------------- |
| `title` | `string` | Yes | Labels the modal and its visible header. |
| `icon` | `ReactNode` | No | Renders before the title in the header. |
| `open` | `boolean` | Yes | Shows the modal content when `true`. |
| `onOpenChange` | `(open: boolean) => void` | Yes | Receives `false` when the user dismisses it. |
| `children` | `ReactNode` | Yes | Contains `Modal.Content`. |
`Modal.Content` owns the body below the host-rendered header:
| Prop | Type | Required | Behavior |
| ---------- | ----------- | -------- | --------------------------------------------- |
| `children` | `ReactNode` | Yes | Renders the plugin's React Native UI content. |
The close button, backdrop, platform back action, web Escape key, and compact sheet gesture dismiss
the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open` to close it.
Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorkspace`, and
`useAgent` work inside them.
### Toasts
`useToast()` returns two methods:
| Method | Behavior |
| ------------------------- | ----------------------------------------------------------- |
| `show(message, options?)` | Shows a toast for 2,200 ms unless `durationMs` is supplied. |
| `error(message)` | Shows an error toast for 3,200 ms. |
`show` accepts these options:
| Option | Type | Default |
| ------------ | ---------------------------------------------------------- | ----------- |
| `variant` | `"default" \| "info" \| "success" \| "warning" \| "error"` | `"default"` |
| `durationMs` | `number` | `2200` |
Showing another toast replaces the currently visible toast. An empty message is ignored.
### Icons
`Icon` renders a [Lucide icon](https://lucide.dev/icons/) from Paseo's installed icon set. Plugin bundles do not import
`lucide-react-native` or `react-native-svg`.
| Prop | Type | Required | Behavior |
| ------- | -------- | -------- | ----------------------------------------------- |
| `name` | `string` | Yes | Lucide icon name. Unknown names render nothing. |
| `size` | `number` | No | Icon width and height. |
| `color` | `string` | No | Icon color. Use a plugin theme token. |
## Timeline items