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