diff --git a/docs/plugins.md b/docs/plugins.md
index e80943d2a9..bca608ba52 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -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 `` and body UI in
+``.
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
diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts
new file mode 100644
index 0000000000..7396610c92
--- /dev/null
+++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts
@@ -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
+ Plugin modal contexts ready
+
+ Save issue
+
+ ;
+}
+
+function Surface() {
+ const [open, setOpen] = useState(false);
+ return
+ setOpen(true)}>
+
+
+ Open plugin modal
+
+
+ }
+ open={open}
+ onOpenChange={setOpen}
+ >
+
+ setOpen(false)} />
+
+
+ ;
+}
+
+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 {
+ await gotoAppShell(page);
+ await page.getByRole("button", { name: "Host UI", exact: true }).click();
+}
+
+async function useNonCompactLayout(page: Page): Promise {
+ await page.setViewportSize({ width: 1100, height: 800 });
+}
+
+async function useCompactLayout(page: Page): Promise {
+ await page.setViewportSize({ width: 390, height: 844 });
+}
+
+async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise {
+ await openMobileAgentSidebar(page);
+ await expectMobileAgentSidebarVisible(page);
+ await page.getByRole("button", { name: "Host UI", exact: true }).click();
+}
+
+async function openPluginModal(page: Page): Promise {
+ await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
+}
+
+async function expectCenteredPluginDialog(page: Page): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 });
+ }
+});
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index b1a3c601f6..bd50928b7b 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -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;
/** 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 (
(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 {children};
+}
diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx
index 1898380edf..8e58bfd8c1 100644
--- a/packages/app/src/contexts/toast-context.tsx
+++ b/packages/app/src/contexts/toast-context.tsx
@@ -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(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 (
-
+
{children}
-
+
);
}
diff --git a/packages/app/src/plugins/catalog-sync.test.tsx b/packages/app/src/plugins/catalog-sync.test.tsx
deleted file mode 100644
index bbb5ce6845..0000000000
--- a/packages/app/src/plugins/catalog-sync.test.tsx
+++ /dev/null
@@ -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 {
- await act(async () => {
- root.render();
- 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);
- });
-});
diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts
index 799a13132d..cff06923d2 100644
--- a/packages/app/src/plugins/evaluate.test.ts
+++ b/packages/app/src/plugins/evaluate.test.ts
@@ -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",
diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts
index b8e9e58ddb..24e6afd538 100644
--- a/packages/app/src/plugins/evaluate.ts
+++ b/packages/app/src/plugins/evaluate.ts
@@ -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 };
}
diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx
new file mode 100644
index 0000000000..38728b56b6
--- /dev/null
+++ b/packages/app/src/plugins/react-native/modal.tsx
@@ -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(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 {children};
+}
+
+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) => (
+
+ {bridgePluginRuntime(content)}
+
+ ),
+ [bridgePluginRuntime, queryClient, toast],
+ );
+ const { AdaptiveModalSheet } =
+ require("../../components/adaptive-modal-sheet") as typeof import("../../components/adaptive-modal-sheet");
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const Modal: ModalComponent = Object.assign(ModalRoot, { Content: ModalContent });
diff --git a/packages/app/src/plugins/react-native/runtime.ts b/packages/app/src/plugins/react-native/runtime.ts
new file mode 100644
index 0000000000..4ab9f0a3a6
--- /dev/null
+++ b/packages/app/src/plugins/react-native/runtime.ts
@@ -0,0 +1,5 @@
+import { Icon } from "../icons";
+import { Modal } from "./modal";
+import { useToast } from "./toast";
+
+export const pluginReactNativeRuntime = { Icon, Modal, useToast };
diff --git a/packages/app/src/plugins/react-native/toast.ts b/packages/app/src/plugins/react-native/toast.ts
new file mode 100644
index 0000000000..e144390bfa
--- /dev/null
+++ b/packages/app/src/plugins/react-native/toast.ts
@@ -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],
+ );
+}
diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts
index 4d93e70370..33177f7d30 100644
--- a/packages/app/src/plugins/registry.ts
+++ b/packages/app/src/plugins/registry.ts
@@ -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
diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts
index 9bda00fe3a..86b762712c 100644
--- a/packages/cli/src/commands/plugin/scaffold.test.ts
+++ b/packages/cli/src/commands/plugin/scaffold.test.ts
@@ -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 <>Paseo API>;
+ return <> toast.show("Ready")}>Paseo API} open={false} onOpenChange={() => {}}>Modal>;
}
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 defeb6aa7a..e9ead5559f 100644
--- a/packages/cli/src/commands/plugin/scaffold.ts
+++ b/packages/cli/src/commands/plugin/scaffold.ts
@@ -56,6 +56,46 @@ const SDK_DECLARATIONS = `declare module "@getpaseo/plugin/server" {
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType;
}
+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 {
+ Content: ComponentType;
+ }
+
+ 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;
+ export const Modal: ModalComponent;
+ export function useToast(): ToastApi;
+}
+
declare module "@getpaseo/plugin" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 3d5d8c3d4e..c9262c8fde 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -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",
diff --git a/packages/plugin/src/client-state.tsx b/packages/plugin/src/client-state.tsx
index d479acd063..9797e8d965 100644
--- a/packages/plugin/src/client-state.tsx
+++ b/packages/plugin/src/client-state.tsx
@@ -11,12 +11,16 @@ export interface PluginClientStateSource {
const PluginClientStateContext = createContext(null);
+export function usePluginClientStateSource(): PluginClientStateSource | null {
+ return useContext(PluginClientStateContext);
+}
+
function usePluginEntity(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,
diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts
index 61810e75d4..9a6dceed7f 100644
--- a/packages/plugin/src/host.ts
+++ b/packages/plugin/src/host.ts
@@ -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,
diff --git a/packages/plugin/src/paseo-context.tsx b/packages/plugin/src/paseo-context.tsx
index 86aa18b3a9..fe1a0b9c5c 100644
--- a/packages/plugin/src/paseo-context.tsx
+++ b/packages/plugin/src/paseo-context.tsx
@@ -3,12 +3,16 @@ import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext(null);
+export function usePaseoContextValue(): PaseoApi | null {
+ return useContext(PaseoApiContext);
+}
+
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
return {children};
}
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;
}
diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/react-native.ts
new file mode 100644
index 0000000000..30f4440c0e
--- /dev/null
+++ b/packages/plugin/src/react-native.ts
@@ -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 {
+ Content: ComponentType;
+}
+
+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;
+export declare const Modal: ModalComponent;
+export declare function useToast(): ToastApi;
+
+export type { PluginIconProps } from "./contracts.js";
diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/rpc-context.tsx
index 104fac8532..8f7ba514f5 100644
--- a/packages/plugin/src/rpc-context.tsx
+++ b/packages/plugin/src/rpc-context.tsx
@@ -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;
}
const PluginRpcContext = createContext(null);
+export function usePluginRpcContextValue(): PluginRpcContextValue | null {
+ return useContext(PluginRpcContext);
+}
+
export function PluginRpcProvider({
children,
invoke,
@@ -22,7 +26,7 @@ export function PluginRpcProvider({
export function useRpc(
contract: PluginRpcContract,
): (input: ZodInput) => Promise> {
- const context = useContext(PluginRpcContext);
+ const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
diff --git a/packages/plugin/src/runtime-context-bridge.tsx b/packages/plugin/src/runtime-context-bridge.tsx
new file mode 100644
index 0000000000..6f0b303e3b
--- /dev/null
+++ b/packages/plugin/src/runtime-context-bridge.tsx
@@ -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 ? (
+ {children}
+ ) : (
+ children
+ );
+ return (
+
+ {content}
+
+ );
+ },
+ [paseo, rpc, state],
+ );
+}
diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts
index 37584ae070..78688f3a2d 100644
--- a/packages/server/src/server/plugins/compiler.test.ts
+++ b/packages/server/src/server/plugins/compiler.test.ts
@@ -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");
diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts
index e9c3e9e735..79e95627e5 100644
--- a/packages/server/src/server/plugins/compiler.ts
+++ b/packages/server/src/server/plugins/compiler.ts
@@ -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`,
diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts
index 31eac46b15..ec3391b1ec 100644
--- a/packages/server/src/server/plugins/plugin-process.ts
+++ b/packages/server/src/server/plugins/plugin-process.ts
@@ -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;
@@ -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);
}
diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts
index 66ff688ce4..3b7384e0e7 100644
--- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts
+++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts
@@ -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);
+}
diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md
index e8d88ceec0..d18b1634f0 100644
--- a/public-docs/plugins/reference.md
+++ b/public-docs/plugins/reference.md
@@ -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 "" 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";
-;
+export function IssueActions({ theme }: PluginSurfaceProps) {
+ const [open, setOpen] = useState(false);
+ const toast = useToast();
+
+ function saveIssue() {
+ toast.show("Issue saved", { variant: "success" });
+ setOpen(false);
+ }
+
+ return (
+
+ setOpen(true)}>
+
+
+ Edit issue
+
+
+
+ }
+ open={open}
+ onOpenChange={setOpen}
+ >
+
+
+ Save
+
+
+
+
+ );
+}
```
-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