mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
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:
+10
-7
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user