mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
refactor(plugin): consolidate the client UI entrypoint
This commit is contained in:
+9
-9
@@ -240,7 +240,7 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
|
||||
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
@@ -257,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
| Entry | Owns | May depend on |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
|
||||
@@ -9,7 +9,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
requestPluginTimeline,
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
@@ -33,3 +34,9 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
|
||||
await expectBothConsecutiveTools(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("inline-thinking example renders a streamed thought through host UI", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await viewInlineThinkingExample(page, info);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
|
||||
}): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/ui";
|
||||
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
|
||||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
@@ -274,3 +274,40 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
const THOUGHT =
|
||||
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
|
||||
|
||||
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
|
||||
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
|
||||
info.setTimeout(120_000);
|
||||
const example = await copyPluginExample("inline-thinking");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-thinking-",
|
||||
title: "Inline thinking example",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await requestPluginTimeline(agent);
|
||||
const thought = page.getByText(/^Need to find the scroll container/);
|
||||
await expect(thought).toBeVisible({ timeout: 60_000 });
|
||||
await expect(thought).not.toHaveText(THOUGHT);
|
||||
await info.attach("inline-thinking-streaming", {
|
||||
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
|
||||
} finally {
|
||||
await client.removePlugin("inline-thinking");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close();
|
||||
await agent.cleanup();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,11 +646,11 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("must return a cleanup function");
|
||||
});
|
||||
|
||||
it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => {
|
||||
it("provides the host Icon component through @getpaseo/plugin/client/ui", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { Icon } = require("@getpaseo/plugin/client/react-native");
|
||||
const { Icon } = require("@getpaseo/plugin/client/ui");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addScreen({ id: "main", title: "Main", Component: function Surface() {
|
||||
@@ -668,14 +668,14 @@ describe("evaluatePluginClientBundle", () => {
|
||||
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
|
||||
});
|
||||
|
||||
it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => {
|
||||
it("provides Paseo UI through @getpaseo/plugin/client/ui", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native");
|
||||
const { Icon, Modal, useToast, SettingsSection, SidebarRow, StreamedText } = require("@getpaseo/plugin/client/ui");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
|
||||
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function" || typeof SettingsSection !== "function" || typeof SidebarRow !== "function" || typeof StreamedText !== "function") {
|
||||
throw new Error("React Native plugin UI is incomplete");
|
||||
}
|
||||
plugin.addSurface("main", function Surface() { return null; });
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
@@ -478,6 +477,8 @@ export function runPluginClientBundle(
|
||||
},
|
||||
};
|
||||
const runtimeRequire = (name: string): unknown => {
|
||||
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
||||
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
|
||||
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
|
||||
if (name === "react") return React;
|
||||
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
||||
@@ -496,9 +497,6 @@ export function runPluginClientBundle(
|
||||
runtime.hosts.getSnapshot,
|
||||
),
|
||||
};
|
||||
if (name === "@getpaseo/plugin/client/react-native") {
|
||||
return pluginReactNativeRuntime;
|
||||
}
|
||||
if (name === "@tanstack/react-query") return ReactQuery;
|
||||
if (name === "zod") return Zod;
|
||||
throw new Error(`Module "${name}" is not available in plugin client code`);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PluginSvgIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { IconProps } from "@getpaseo/plugin/client/react-native";
|
||||
import type { IconProps } from "@getpaseo/plugin/client/ui";
|
||||
import { SvgIcon } from "@/components/provider-icons";
|
||||
|
||||
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ModalComponent,
|
||||
ModalContentProps,
|
||||
ModalProps,
|
||||
} from "@getpaseo/plugin/client/react-native";
|
||||
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/client/ui";
|
||||
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { ScrollView, FlatList } from "./scroll-view";
|
||||
import { TextInput } from "./text-input";
|
||||
import { copyText } from "./clipboard";
|
||||
import { useToast } from "./toast";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
|
||||
export const pluginReactNativeRuntime = {
|
||||
Icon,
|
||||
Modal,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TextInput,
|
||||
copyText,
|
||||
useRevealedText,
|
||||
useToast,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { StreamedTextProps } from "@getpaseo/plugin/client/ui";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
|
||||
export function StreamedText({ text, phase, style, ...props }: StreamedTextProps) {
|
||||
const revealed = useRevealedText(text, phase);
|
||||
return (
|
||||
<Text {...props} style={[styles.text, style]}>
|
||||
{revealed}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native";
|
||||
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/ui";
|
||||
import { useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export {
|
||||
SettingsGroup,
|
||||
SettingsSection,
|
||||
SettingsCard,
|
||||
SettingsRow,
|
||||
@@ -16,3 +15,14 @@ export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
export { DocumentFrame } from "@/file-panels/document-frame";
|
||||
|
||||
export { FilePanelLoading as Loading } from "@/file-panels/loading";
|
||||
|
||||
// COMPAT(pluginSettingsGroup): added in v0.13.0, remove after 2027-04-20
|
||||
export { SettingsGroup } from "@/components/settings";
|
||||
|
||||
export { Icon } from "../icons";
|
||||
export { Modal } from "./modal";
|
||||
export { ScrollView, FlatList } from "./scroll-view";
|
||||
export { TextInput } from "./text-input";
|
||||
export { copyText } from "./clipboard";
|
||||
export { useToast } from "./toast";
|
||||
export { StreamedText } from "./streamed-text";
|
||||
|
||||
@@ -196,7 +196,7 @@ export async function inspectConfig(
|
||||
path.join(directory, "client", "main.tsx"),
|
||||
`import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||||
import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
|
||||
import { inspect } from "../shared/inspect";
|
||||
|
||||
|
||||
@@ -15,9 +15,14 @@ const entries = {
|
||||
"./server/acp": "server",
|
||||
"./client": "client",
|
||||
"./client/host": "client",
|
||||
"./client/react-native": "client",
|
||||
"./client/ui": "client",
|
||||
} as const;
|
||||
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
// Compatibility entrypoints inherit the boundary of their one re-export target.
|
||||
const compatibilityEntries = Object.keys(manifest.exports).filter((entry) => {
|
||||
const file = path.resolve(sourceDirectory, "..", manifest.exports[entry].source);
|
||||
return readFileSync(file, "utf8").startsWith("// COMPAT(");
|
||||
});
|
||||
const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/;
|
||||
const serverModule = /^server\//;
|
||||
const clientModule = /^client\//;
|
||||
@@ -70,8 +75,17 @@ describe("plugin SDK import boundaries", () => {
|
||||
});
|
||||
|
||||
it("classifies every published entry", () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
|
||||
expect(Object.keys(manifest.exports).sort()).toEqual(
|
||||
[...Object.keys(entries), ...compatibilityEntries].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => {
|
||||
const source = readFileSync(
|
||||
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
|
||||
"utf8",
|
||||
);
|
||||
expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";');
|
||||
});
|
||||
|
||||
it.each(Object.entries(entries))(
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import type { ComponentProps } from "react";
|
||||
import type {
|
||||
StreamedText,
|
||||
StreamedTextProps,
|
||||
Modal,
|
||||
ModalProps,
|
||||
SettingsSection,
|
||||
SettingsSectionProps,
|
||||
} from "./ui.js";
|
||||
import { z } from "zod";
|
||||
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js";
|
||||
import { callPluginRpc, searchPluginAttachments } from "./host.js";
|
||||
@@ -107,3 +116,12 @@ describe("plugin attachment sources", () => {
|
||||
).rejects.toBeInstanceOf(z.ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin UI contracts", () => {
|
||||
it("exposes presentation capabilities from the UI entry", () => {
|
||||
expectTypeOf<ComponentProps<typeof StreamedText>>().toEqualTypeOf<StreamedTextProps>();
|
||||
expectTypeOf<ComponentProps<typeof Modal>>().toEqualTypeOf<ModalProps>();
|
||||
expectTypeOf<ComponentProps<typeof SettingsSection>>().toEqualTypeOf<SettingsSectionProps>();
|
||||
expectTypeOf<StreamedTextProps["phase"]>().toEqualTypeOf<"streaming" | "complete">();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,3 @@
|
||||
import type {
|
||||
ComponentType,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
ReactElement,
|
||||
Ref,
|
||||
} from "react";
|
||||
import type {
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
ScrollView as NativeScrollView,
|
||||
ScrollViewProps,
|
||||
FlatList as NativeFlatList,
|
||||
FlatListProps,
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
/** Paint the full body below the header. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
/** Default true. Set false for a bounded body with your own ScrollView or FlatList. */
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
|
||||
export type IconProps = Partial<PluginIconProps> &
|
||||
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
|
||||
|
||||
export declare const Icon: ComponentType<IconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
|
||||
|
||||
export type { PluginIconProps } from "../icons.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
ScrollViewProps & RefAttributes<NativeScrollView>
|
||||
>;
|
||||
export declare function FlatList<Item>(
|
||||
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
|
||||
): ReactElement;
|
||||
/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */
|
||||
export declare function copyText(text: string): Promise<void>;
|
||||
|
||||
/** Native input focus integrated with modal keyboard positioning. */
|
||||
export declare const TextInput: ForwardRefExoticComponent<
|
||||
TextInputProps & RefAttributes<NativeTextInput>
|
||||
>;
|
||||
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
||||
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
|
||||
export * from "./ui.js";
|
||||
|
||||
@@ -1,6 +1,91 @@
|
||||
import type { ComponentType, ReactNode, Ref } from "react";
|
||||
import type {
|
||||
ComponentType,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
ReactElement,
|
||||
Ref,
|
||||
} from "react";
|
||||
import type {
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
ScrollView as NativeScrollView,
|
||||
ScrollViewProps,
|
||||
FlatList as NativeFlatList,
|
||||
FlatListProps,
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
TextProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginIcon } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
/** Paint the full body below the header. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
/** Default true. Set false for a bounded body with your own ScrollView or FlatList. */
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
|
||||
export type IconProps = Partial<PluginIconProps> &
|
||||
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
|
||||
|
||||
export declare const Icon: ComponentType<IconProps>;
|
||||
export declare const Modal: ModalComponent;
|
||||
export declare function useToast(): ToastApi;
|
||||
export interface StreamedTextProps extends Omit<TextProps, "children"> {
|
||||
text: string;
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
/** Plain assistant text with host-owned streaming pacing and typography. */
|
||||
export declare const StreamedText: ComponentType<StreamedTextProps>;
|
||||
|
||||
export type { PluginIconProps } from "../icons.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
ScrollViewProps & RefAttributes<NativeScrollView>
|
||||
>;
|
||||
export declare function FlatList<Item>(
|
||||
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
|
||||
): ReactElement;
|
||||
/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */
|
||||
export declare function copyText(text: string): Promise<void>;
|
||||
|
||||
/** Native input focus integrated with modal keyboard positioning. */
|
||||
export declare const TextInput: ForwardRefExoticComponent<
|
||||
TextInputProps & RefAttributes<NativeTextInput>
|
||||
>;
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
title: string;
|
||||
info?: ReactNode;
|
||||
@@ -45,6 +130,8 @@ export interface SettingsActionProps extends SettingsRowProps {
|
||||
onPress(): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
// COMPAT(pluginSettingsGroup): added in v0.13.0, remove after 2027-04-20
|
||||
/** @deprecated Use SettingsSection for plugin settings headings. */
|
||||
export declare const SettingsGroup: ComponentType<SettingsSectionProps>;
|
||||
export declare const SettingsSection: ComponentType<SettingsSectionProps>;
|
||||
export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
|
||||
"@getpaseo/plugin/client",
|
||||
"@getpaseo/plugin/client/ui",
|
||||
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
|
||||
"@getpaseo/plugin/client/react-native",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
||||
import { useRevealedText } from "@getpaseo/plugin/client/react-native";
|
||||
import { StreamedText } from "@getpaseo/plugin/client/ui";
|
||||
import { useMemo } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
export const inlineThinkingSchema = z.object({
|
||||
@@ -12,10 +11,9 @@ export const inlineThinkingSchema = z.object({
|
||||
type InlineThinkingData = z.output<typeof inlineThinkingSchema>;
|
||||
|
||||
export function InlineThinking({ item, theme }: PluginTimelineItemProps<InlineThinkingData>) {
|
||||
const text = useRevealedText(item.data.text, item.data.phase);
|
||||
const style = useMemo(
|
||||
() => ({ color: theme.colors.foregroundMuted }),
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return <Text style={style}>{text}</Text>;
|
||||
return <StreamedText text={item.data.text} phase={item.data.phase} style={style} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "inline-thinking",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
"paseo": ">=0.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TextInput,
|
||||
copyText,
|
||||
useToast,
|
||||
} from "@getpaseo/plugin/client/react-native";
|
||||
} from "@getpaseo/plugin/client/ui";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "modal-ui-example",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
"paseo": ">=0.13.0"
|
||||
},
|
||||
"description": "Interactive modal forms, scrolling lists, and clipboard examples"
|
||||
}
|
||||
|
||||
@@ -134,21 +134,22 @@ show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popove
|
||||
|
||||
Move hooks (`usePaseo`, `useRpc`, `useSettings`, `useAgent`, `useWorkspace`) and client contribution
|
||||
types from `@getpaseo/plugin` to `@getpaseo/plugin/client`. Move `Icon` to
|
||||
`@getpaseo/plugin/client/react-native`. Import server contexts and lifecycle contracts from
|
||||
`@getpaseo/plugin/client/ui`. Import server contexts and lifecycle contracts from
|
||||
`@getpaseo/plugin/server`. Shared helpers (`defineRpc`, `defineSettings`, `defineAttachmentSource`),
|
||||
schemas, and plain data types stay on the root. These rules include type imports. See
|
||||
[Runtime modules](reference#runtime-modules) for the complete contract.
|
||||
|
||||
Move the remaining SDK subpaths under their runtime owner:
|
||||
|
||||
| Old entry | 0.8 entry |
|
||||
| ------------------------------- | -------------------------------------- |
|
||||
| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/react-native` |
|
||||
| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` |
|
||||
| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` |
|
||||
| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` |
|
||||
| Old entry | Current entry |
|
||||
| -------------------------------------- | ---------------------------------- |
|
||||
| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/ui` |
|
||||
| `@getpaseo/plugin/client/react-native` | `@getpaseo/plugin/client/ui` |
|
||||
| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` |
|
||||
| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` |
|
||||
| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` |
|
||||
|
||||
The old entries and the pre-0.8 `@paseo/plugin` scope are removed. `/client/host` is private to
|
||||
The pre-0.8 entries and the old `@paseo/plugin` scope are removed. `/client/host` is private to
|
||||
Paseo's app integration and is never a plugin-author import.
|
||||
|
||||
The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports
|
||||
@@ -156,6 +157,15 @@ only `server/`, `shared/`, and server-safe packages. A `node:` import in the cli
|
||||
reachable from it is a compile error. Never import a component into the server entry merely to wire
|
||||
its registration; that registration belongs in the client entry.
|
||||
|
||||
### UI imports in v0.13
|
||||
|
||||
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
|
||||
undocumented compatibility alias until 2027-04-20.
|
||||
|
||||
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
|
||||
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
|
||||
Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated.
|
||||
|
||||
## 5. Recognize half-migration errors
|
||||
|
||||
| Compiler or load error | Meaning and fix |
|
||||
|
||||
@@ -138,24 +138,23 @@ only shared code: no Node, React, runtime-specific SDK entries, or runtime-speci
|
||||
|
||||
The SDK root (`@getpaseo/plugin`) contains shared data, schemas, and runtime-neutral helpers only.
|
||||
Import client contexts and hooks from `/client`, server contexts and lifecycle contracts from
|
||||
`/server`, and UI from `/client/react-native` or `/client/ui`. These rules include type imports and transitive
|
||||
`/server`, and UI from `/client/ui`. These rules include type imports and transitive
|
||||
dependencies. `/client/host` is private to the app host; plugins cannot import it.
|
||||
|
||||
### Client runtime
|
||||
|
||||
Paseo provides these modules to client code:
|
||||
|
||||
| Module | Use it for |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` |
|
||||
| `@getpaseo/plugin/client/ui` | Named, composable settings components |
|
||||
| `@getpaseo/plugin/client/react-native` | Paseo UI components and UI hooks |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks |
|
||||
| `@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` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` |
|
||||
| `@getpaseo/plugin/client/ui` | Paseo UI components and UI hooks |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks |
|
||||
| `@tanstack/react-query` | Request state and caching |
|
||||
| `react` | Components and hooks |
|
||||
| `react/jsx-runtime` | Compiled JSX |
|
||||
| `react-native` | Cross-platform UI |
|
||||
| `zod` | Shared schemas |
|
||||
|
||||
The host owns its paired React and renderer versions. The SDK's React peer range permits patch
|
||||
versions for tooling and Node consumers; it does not change the app's pinned React version or
|
||||
@@ -1072,12 +1071,12 @@ function hideAlerts() {
|
||||
|
||||
## Host UI
|
||||
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/client/react-native` in client code. This example
|
||||
Import Paseo-owned UI from `@getpaseo/plugin/client/ui` in client code. This example
|
||||
opens a controlled modal, renders a host icon, and confirms the action with a toast:
|
||||
|
||||
```tsx
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
@@ -1156,7 +1155,7 @@ Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorksp
|
||||
|
||||
### Scrolling
|
||||
|
||||
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/react-native` when content can appear in a
|
||||
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/ui` when content can appear in a
|
||||
Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside
|
||||
a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly.
|
||||
|
||||
@@ -1170,7 +1169,7 @@ container without changing these gestures. Expand the sheet before using list me
|
||||
`scrollToEnd`; the sheet locks list offsets below its largest height.
|
||||
|
||||
```tsx
|
||||
import { FlatList, Modal } from "@getpaseo/plugin/client/react-native";
|
||||
import { FlatList, Modal } from "@getpaseo/plugin/client/ui";
|
||||
import { Text } from "react-native";
|
||||
|
||||
// Inside your controlled Modal:
|
||||
@@ -1200,7 +1199,7 @@ user action and await it before reporting success. It rejects if the platform de
|
||||
clipboard is unavailable; browser permissions and secure-context requirements still apply.
|
||||
|
||||
```tsx
|
||||
import { copyText, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { copyText, useToast } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
// Inside your component:
|
||||
const toast = useToast();
|
||||
@@ -1215,7 +1214,7 @@ async function copyResult() {
|
||||
```
|
||||
|
||||
Programmatic copying and native text selection are separate interactions. Use `<Text selectable>`
|
||||
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/react-native` for modal forms. It accepts React Native
|
||||
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/ui` for modal forms. It accepts React Native
|
||||
input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard
|
||||
can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports
|
||||
Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read
|
||||
@@ -1310,7 +1309,7 @@ client.addWorkspacePanel({ id: "review", title: "Review", icon: { svg: reviewIco
|
||||
|
||||
#### Render an icon
|
||||
|
||||
`Icon` from `@getpaseo/plugin/client/react-native` draws an icon in your own UI. Plugin bundles do
|
||||
`Icon` from `@getpaseo/plugin/client/ui` draws an icon in your own UI. Plugin bundles do
|
||||
not import `lucide-react-native` or `react-native-svg`.
|
||||
|
||||
| Prop | Type | Required | Behavior |
|
||||
@@ -1381,9 +1380,10 @@ source item's output.
|
||||
Renderers receive `agentId`, `item`, `timestamp`, `theme`, `host`, and `layout`. Paseo validates
|
||||
`item.data` with the registered schema before rendering. Keep transformers synchronous and
|
||||
deterministic. Paseo memoizes results by source-item reference and derives replacement identity from
|
||||
the source row, so updates to one streaming item do not remount its renderer. Use the exported
|
||||
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
|
||||
assistant rows.
|
||||
the source row, so updates to one streaming item do not remount its renderer. Render `<StreamedText text={item.data.text} phase={item.data.phase} />` from
|
||||
`@getpaseo/plugin/client/ui` for plain text paced like Paseo's assistant rows. It accepts React Native
|
||||
`Text` props (including `style`) and supplies assistant typography and foreground color by default.
|
||||
Completion displays the full text immediately; the host owns pacing while streaming. Requires Paseo 0.13 or later.
|
||||
|
||||
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
|
||||
matches the original message. The host searches the original prompt text or the assistant reply
|
||||
@@ -1540,15 +1540,15 @@ export function DisplaySettings() {
|
||||
}
|
||||
```
|
||||
|
||||
| Component | Props and behavior |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SettingsGroup`, `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. |
|
||||
| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. |
|
||||
| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. |
|
||||
| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. |
|
||||
| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. |
|
||||
| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. |
|
||||
| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. |
|
||||
| Component | Props and behavior |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. |
|
||||
| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. |
|
||||
| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. |
|
||||
| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. |
|
||||
| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. |
|
||||
| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. |
|
||||
| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. |
|
||||
|
||||
`SettingsInput` owns in-progress text. `initialValue` seeds it when mounted. Its ref exposes
|
||||
`focus()`, `blur()`, `getText()`, and `replaceText(text)` for explicit programmatic changes.
|
||||
|
||||
@@ -261,7 +261,7 @@ Icons are Lucide icon names, inline SVG (`{ svg }`), or components; see [Icons](
|
||||
Before writing imports, classify each module as shared, client, or server. Follow the
|
||||
[SDK import boundaries](https://paseo.sh/docs/plugins/reference.md#runtime-modules), including
|
||||
transitive and type dependencies. The root is shared-only; hooks and client contexts belong to
|
||||
`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`.
|
||||
`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/ui`.
|
||||
Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the
|
||||
automatic runtime. Do not import `/client/host` from plugin code.
|
||||
|
||||
@@ -568,7 +568,7 @@ client.addTimelineRenderer({
|
||||
});
|
||||
```
|
||||
|
||||
Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/client/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card.
|
||||
Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Render `<StreamedText text={text} phase={phase} />` from `@getpaseo/plugin/client/ui` for paced assistant text; use its `style` prop to customize text appearance. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a todo tool call with a task card.
|
||||
|
||||
## Append a timeline row from the daemon
|
||||
|
||||
|
||||
Reference in New Issue
Block a user