Let plugins control modal layout, scrolling and clipboard actions (#4392)

* feat(plugins): support modal body layout, scrolling and clipboard actions

* fix(app): keep native scroll dependencies out of web imports

* fix(nix): sync dependency hash for plugin native types

* test(plugins): express modal QA as user journeys
This commit is contained in:
Mohamed Boudra
2026-09-06 13:48:29 +02:00
committed by GitHub
parent c99b8ad5f4
commit 38c22139bb
33 changed files with 822 additions and 123 deletions
+2 -1
View File
@@ -155,7 +155,8 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
`@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>`.
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
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/v0.8/reference.md`.
+1 -1
View File
@@ -1 +1 @@
sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE=
sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA=
+7
View File
@@ -38700,6 +38700,7 @@
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"react-native": "0.81.5",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
@@ -38708,7 +38709,13 @@
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
}
},
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
@@ -0,0 +1,163 @@
import path from "node:path";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
openMobileAgentSidebar,
expectMobileAgentSidebarVisible,
} from "../support/helpers/sidebar";
async function openExample(page: Page, example: string) {
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible();
}
async function closeExample(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
}
async function copyAndPaste(page: Page) {
await page.getByRole("button", { name: "Copy text", exact: true }).click();
await expect(page.getByText("Text copied", { exact: true })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo");
await page.getByRole("textbox", { name: "Paste here" }).focus();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("ControlOrMeta+V");
await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo");
}
async function scrollToLastRow(page: Page) {
await page.getByRole("button", { name: "Row 1", exact: true }).hover();
await page.mouse.wheel(0, 7000);
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
await page.getByRole("button", { name: "Row 100", exact: true }).click();
}
async function expectSingleInset(page: Page, left = "24px", bottom = "24px") {
const form = await page.getByTestId("modal-example-form").boundingBox();
const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox();
expect(form).not.toBeNull();
expect(input?.x).toBe(form?.x);
// The real content View owns the inset, not a third-party contentContainerStyle.
const padding = await page.getByTestId("modal-example-form").evaluate((node) => {
const content = node.parentElement!;
return {
left: getComputedStyle(content).paddingLeft,
bottom: getComputedStyle(content).paddingBottom,
};
});
expect(padding).toEqual({ left, bottom });
}
async function expectFullBleed(page: Page) {
const padding = await page
.getByRole("button", { name: "Row 1", exact: true })
.evaluate((node) => {
const content = node.parentElement!;
return {
left: getComputedStyle(content).paddingLeft,
bottom: getComputedStyle(content).paddingBottom,
};
});
expect(padding).toEqual({ left: "0px", bottom: "0px" });
}
async function exerciseBodies(page: Page) {
await openExample(page, "Form");
await expectSingleInset(page);
await copyAndPaste(page);
await closeExample(page);
await openExample(page, "Custom inset");
await expectSingleInset(page, "8px", "40px");
await closeExample(page);
await openExample(page, "Full bleed");
await expectFullBleed(page);
await scrollToLastRow(page);
await closeExample(page);
await openExample(page, "ScrollView");
await scrollToLastRow(page);
await closeExample(page);
await openExample(page, "FlatList");
await page.getByRole("button", { name: "Jump to last row", exact: true }).click();
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
await closeExample(page);
await openExample(page, "Horizontal");
await page.getByRole("button", { name: "Overview", exact: true }).hover();
await page.mouse.wheel(900, 0);
await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport();
await page.getByRole("button", { name: "System logs", exact: true }).click();
await closeExample(page);
await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible();
}
const test = base.extend<{ modalExample: Page }>({
modalExample: async ({ page, context }, provide) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(
path.resolve(__dirname, "../../../../plugin-examples/modal-ui"),
);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await provide(page);
} finally {
await client.removePlugin("modal-ui-example").catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
}
},
});
async function openWideExamples(page: Page) {
await page.setViewportSize({ width: 1100, height: 800 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
}
async function openCompactExamples(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
}
async function expectDeniedCopyFeedback(page: Page) {
const cdp = await page.context().newCDPSession(page);
try {
const { targetInfo } = await cdp.send("Target.getTargetInfo");
await cdp.send("Browser.setPermission", {
browserContextId: targetInfo.browserContextId,
permission: { name: "clipboard-write" },
setting: "denied",
origin: new URL(page.url()).origin,
});
expect(
await page.evaluate(
async () =>
(await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state,
),
).toBe("denied");
await openExample(page, "Form");
await page.getByRole("button", { name: "Copy text", exact: true }).click();
await expect(
page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }),
).toBeVisible();
await closeExample(page);
} finally {
await cdp.detach();
}
}
test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({
modalExample: page,
}) => {
await openWideExamples(page);
await test.step("wide dialogs", () => exerciseBodies(page));
await openCompactExamples(page);
await test.step("compact sheets", () => exerciseBodies(page));
await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page));
});
@@ -5,30 +5,26 @@ import {
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("adds the bottom inset to compact sheet footers", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: true,
isKeyboardVisible: false,
hasFooter: true,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({ footerPaddingBottom: 46 });
).toEqual({ footerPaddingBottom: 34 });
});
it("adds the bottom inset to compact sheet content when there is no footer", () => {
it("assigns safe clearance to the body only without a footer", () => {
expect(
getCompactSheetSafeAreaPadding({
isCompact: true,
isKeyboardVisible: false,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({ contentPaddingBottom: 58 });
).toEqual({ contentPaddingBottom: 34 });
});
it("does not add a safe-area band above the compact keyboard", () => {
@@ -37,8 +33,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
isCompact: true,
isKeyboardVisible: true,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({});
@@ -50,8 +44,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
isCompact: false,
isKeyboardVisible: false,
hasFooter: false,
baseContentPadding: 24,
baseFooterPadding: 12,
safeAreaBottom: 34,
}),
).toEqual({});
@@ -2,8 +2,6 @@ export interface CompactSheetSafeAreaPaddingInput {
isCompact: boolean;
isKeyboardVisible: boolean;
hasFooter: boolean;
baseContentPadding: number;
baseFooterPadding: number;
safeAreaBottom: number;
}
@@ -38,8 +36,6 @@ export function getCompactSheetSafeAreaPadding({
isCompact,
isKeyboardVisible,
hasFooter,
baseContentPadding,
baseFooterPadding,
safeAreaBottom,
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) {
@@ -47,8 +43,8 @@ export function getCompactSheetSafeAreaPadding({
}
if (hasFooter) {
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
return { footerPaddingBottom: safeAreaBottom };
}
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
return { contentPaddingBottom: safeAreaBottom };
}
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { Modal, Platform, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -14,7 +14,6 @@ import {
} from "../lib/overlay-root";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
@@ -30,6 +29,7 @@ import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -67,6 +67,7 @@ export interface SheetHeader {
search?: SheetHeaderSearch;
}
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
const styles = StyleSheet.create((theme) => ({
@@ -79,6 +80,7 @@ const styles = StyleSheet.create((theme) => ({
pointerEvents: "auto" as const,
},
desktopCard: {
overflow: "hidden",
width: "100%",
maxWidth: 520,
maxHeight: "85%",
@@ -445,8 +447,12 @@ export interface AdaptiveModalSheetProps {
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
/** Bound an author-owned list without changing content-sized first-party dialogs. */
desktopHeight?: DimensionValue;
scrollable?: boolean;
presentation?: "push" | "replace";
/** Full body viewport below the header, including space beyond the content. */
bodyStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
@@ -466,10 +472,12 @@ export function AdaptiveModalSheet({
snapPoints,
testID,
desktopMaxWidth,
desktopHeight,
scrollable = true,
presentation,
contentStyle,
sizeContentToCurrentSnapPoint = false,
bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -484,43 +492,22 @@ export function AdaptiveModalSheet({
isCompact: isMobile,
isKeyboardVisible,
hasFooter: Boolean(footer),
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
baseFooterPadding: theme.spacing[3],
safeAreaBottom: insets.bottom,
}),
[footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing],
[footer, insets.bottom, isKeyboardVisible, isMobile],
);
const compactContentStyle = useMemo(
() => [
contentStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentStyle],
);
const compactStaticContentStyle = useMemo(
() => [styles.compactStaticContent, compactContentStyle],
[compactContentStyle],
);
const desktopScrollContentStyle = useMemo(
() => [styles.contentGrow, contentStyle],
[contentStyle],
);
const desktopStaticContentStyle = useMemo(
() => [styles.desktopStaticContent, contentStyle],
[contentStyle],
);
const footerStyle = useMemo(
() => [
styles.footer,
footerContainerStyle,
compactSafeAreaPadding.footerPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
: null,
],
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
// Safe-area clearance is a separate layer: it must not replace the caller's
// padding (including an explicit zero), and the footer owns it when present.
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
const footerClearanceStyle = useMemo(
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null;
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
@@ -554,8 +541,12 @@ export function AdaptiveModalSheet({
);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
() => [
styles.desktopCard,
desktopHeight != null && { height: desktopHeight },
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
],
[desktopMaxWidth, desktopHeight],
);
const desktopOverlayStyle = useMemo(
() => [
@@ -620,18 +611,27 @@ export function AdaptiveModalSheet({
const sheetContent = (
<>
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<SheetContent style={compactContentStyle}>{children}</SheetContent>
</BottomSheetScrollView>
) : (
<SheetContent style={compactStaticContentStyle}>{children}</SheetContent>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
<View style={[styles.compactStaticContent, bodyStyle]}>
{scrollable ? (
<ScrollView
style={styles.bottomSheetVisibleScroll}
contentContainerStyle={SCROLL_CONTENT_GROW}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.contentGrow, bodyClearanceStyle]}>
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
</View>
</ScrollView>
) : (
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
{children}
</SheetContent>
</View>
)}
</View>
{footerView}
</>
);
@@ -646,6 +646,9 @@ export function AdaptiveModalSheet({
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
// A custom scroll owner must also own body gestures. Gorhom otherwise
// locks even imperative list offsets until the sheet reaches its top snap.
enableContentPanningGesture={scrollable}
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
@@ -662,24 +665,26 @@ export function AdaptiveModalSheet({
);
}
const desktopStaticStyle =
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
const cardInner = (
<OverlayLayerProvider layer={modalLayer}>
<SheetHeaderView header={header} onClose={onClose} />
{scrollable ? (
<View style={styles.desktopScrollContainer}>
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.contentGrow}
contentContainerStyle={SCROLL_CONTENT_GROW}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
<SheetContent style={desktopScrollContentStyle}>{children}</SheetContent>
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
</ScrollView>
</View>
) : (
<SheetContent style={desktopStaticContentStyle}>{children}</SheetContent>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
) : (
<SheetContent style={[desktopStaticStyle, contentStyle]}>{children}</SheetContent>
)}
</View>
{footerView}
</OverlayLayerProvider>
);
@@ -13,7 +13,10 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import {
FlatList as SheetFlatList,
ScrollView as SheetScrollView,
} from "@/components/ui/scroll-view";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -1190,7 +1193,7 @@ function ModelRowList({
if (isCompact && isNative) {
return (
<BottomSheetFlatList
<SheetFlatList
data={rows}
renderItem={renderItem}
ListHeaderComponent={header}
@@ -1488,7 +1491,7 @@ function ModelBrowserContent({
return scrolling === "independent" ? (
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
) : (
<BottomSheetScrollView
<SheetScrollView
style={styles.virtualizedModelList}
contentContainerStyle={[
styles.virtualizedModelListContent,
@@ -1500,7 +1503,7 @@ function ModelBrowserContent({
testID="compact-provider-list"
>
{allProvidersContent}
</BottomSheetScrollView>
</SheetScrollView>
);
}
@@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react";
const BottomSheetScopeContext = createContext(false);
export function BottomSheetTextInputScope({ children }: { children: ReactNode }) {
export function BottomSheetScope({ children }: { children: ReactNode }) {
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
}
@@ -58,7 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
function PortalHostProbe() {
@@ -9,7 +9,7 @@ import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope";
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
@@ -65,9 +65,7 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose
stackBehavior={presentation}
>
<BottomSheetTextInputScope>
{contextBridge ? contextBridge(children) : children}
</BottomSheetTextInputScope>
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
</GorhomBottomSheetModal>
);
@@ -0,0 +1,61 @@
import { forwardRef, type Ref } from "react";
import {
ScrollView as NativeScrollView,
FlatList as NativeFlatList,
type ScrollViewProps,
type FlatListProps,
} from "react-native";
import { BottomSheetScrollView, BottomSheetFlatList } from "@gorhom/bottom-sheet";
import {
ScrollView as GestureScrollView,
FlatList as GestureFlatList,
type NativeViewGestureHandlerProps,
} from "react-native-gesture-handler";
import { isNative } from "@/constants/platform";
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
// Both libraries forward to the RN FlatList instance. Their published ref types
// disagree about getScrollResponder (Gorhom) and include the component function
// in the instance intersection (RNGH); keep that type discrepancy at this boundary.
const SheetFlatList = BottomSheetFlatList as unknown as typeof NativeFlatList;
const HorizontalFlatList = GestureFlatList as unknown as <Item>(
props: FlatListProps<Item> & NativeViewGestureHandlerProps & { ref?: Ref<NativeFlatList<Item>> },
) => React.ReactElement;
/** The presentation owns gesture integration; callers keep the React Native API. */
export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
function ScrollView(props, ref) {
const inSheet = useIsInsideBottomSheet();
if (!inSheet) return <NativeScrollView {...props} ref={ref} />;
if (props.horizontal) {
return isNative ? (
<GestureScrollView {...props} ref={ref} disallowInterruption />
) : (
<NativeScrollView {...props} ref={ref} />
);
}
return (
<BottomSheetScrollView {...props} ref={ref}>
{props.children}
</BottomSheetScrollView>
);
},
);
function SheetAwareFlatList<Item>(props: FlatListProps<Item>, ref: Ref<NativeFlatList<Item>>) {
const inSheet = useIsInsideBottomSheet();
if (!inSheet) return <NativeFlatList {...props} ref={ref} />;
if (props.horizontal) {
return isNative ? (
<HorizontalFlatList {...props} ref={ref} disallowInterruption />
) : (
<NativeFlatList {...props} ref={ref} />
);
}
return <SheetFlatList {...props} ref={ref} />;
}
// forwardRef erases the item generic. All three implementations expose the RN list methods.
export const FlatList = forwardRef(SheetAwareFlatList) as <Item>(
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
) => React.ReactElement;
@@ -0,0 +1,3 @@
// Web dialogs use ordinary scrolling at every breakpoint. Keep native gesture
// dependencies out of this module's import graph.
export { ScrollView, FlatList } from "react-native";
@@ -1,14 +1,8 @@
import { useMemo, type ReactNode } from "react";
import {
ScrollView,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScrollView } from "./scroll-view";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -87,7 +81,7 @@ export function ScrollableCodeSurface({
[maxHeight],
);
const outerScrollStyle = useMemo(
() => [maxHeightStyle, scrollStyle],
() => [styles.scroll, maxHeightStyle, scrollStyle],
[maxHeightStyle, scrollStyle],
);
const contentStyle = useMemo(
@@ -108,24 +102,21 @@ export function ScrollableCodeSurface({
<SurfaceCard
tone={tone}
bordered={bordered}
style={style}
style={[styles.scroll, style]}
testID={testID}
accessibilityLabel={accessibilityLabel}
codeSurface
>
<ScrollView
style={outerScrollStyle}
contentContainerStyle={contentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{horizontal ? (
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
{codeContent}
</ScrollView>
) : (
codeContent
)}
<ScrollView style={outerScrollStyle} nestedScrollEnabled showsVerticalScrollIndicator>
<View style={contentStyle}>
{horizontal ? (
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
{codeContent}
</ScrollView>
) : (
codeContent
)}
</View>
</ScrollView>
</SurfaceCard>
);
@@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
}
const styles = StyleSheet.create((theme) => ({
scroll: {
flexShrink: 1,
minHeight: 0,
},
container: {
overflow: "hidden",
},
@@ -0,0 +1,24 @@
import { forwardRef, useCallback } from "react";
import { TextInput as NativeTextInput, type TextInputProps } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { isNative } from "@/constants/platform";
import { useIsInsideBottomSheet } from "../bottom-sheet-scope";
/** Registers focus with the sheet so the keyboard can move the active input into view. */
export const SheetAwareTextInput = forwardRef<NativeTextInput, TextInputProps>(
function SheetAwareTextInput(props, ref) {
const inSheet = useIsInsideBottomSheet();
const setRef = useCallback(
(input: NativeTextInput | null | undefined) => {
if (typeof ref === "function") ref(input ?? null);
else if (ref) ref.current = input ?? null;
},
[ref],
);
return inSheet && isNative ? (
<BottomSheetTextInput {...props} ref={setRef} />
) : (
<NativeTextInput {...props} ref={setRef} />
);
},
);
@@ -2,7 +2,7 @@
import React, { act, createRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BottomSheetTextInputScope } from "./bottom-sheet-scope";
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
import { EditingTextInput } from "./text-input.native";
import type { EditingTextInputHandle } from "./types";
@@ -62,9 +62,9 @@ describe("EditingTextInputNative", () => {
root?.render(
<>
<EditingTextInput testID="outside" />
<BottomSheetTextInputScope>
<BottomSheetScope>
<EditingTextInput testID="inside" />
</BottomSheetTextInputScope>
</BottomSheetScope>
</>,
);
});
@@ -5,7 +5,7 @@ import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
type NativeInput = (TextInput | PasteTextInputInstance) & {
@@ -0,0 +1,6 @@
export async function copyText(text: string): Promise<void> {
const { setStringAsync } = require("expo-clipboard") as typeof import("expo-clipboard");
if (!(await setStringAsync(text))) {
throw new Error("Clipboard copy was denied or is unavailable");
}
}
@@ -0,0 +1,6 @@
// Expo's web fallback reports success even when document.execCommand returns
// false. Use the browser promise so the SDK can promise success or rejection.
export async function copyText(text: string): Promise<void> {
if (!navigator.clipboard) throw new Error("Clipboard copy is unavailable in this browser");
await navigator.clipboard.writeText(text);
}
@@ -30,7 +30,12 @@ function useModalContext(): ModalContextValue {
return context;
}
function ModalContent({ children }: ModalContentProps) {
function ModalContent({
children,
style,
contentContainerStyle,
scrollable = true,
}: ModalContentProps) {
const modal = useModalContext();
const queryClient = useQueryClient();
const toast = useAppToast();
@@ -52,6 +57,10 @@ function ModalContent({ children }: ModalContentProps) {
onClose={modal.dismiss}
header={modal.header}
contextBridge={contextBridge}
bodyStyle={style}
contentStyle={contentContainerStyle}
scrollable={scrollable}
desktopHeight={scrollable ? undefined : "85%"}
>
{children}
</AdaptiveModalSheet>
@@ -1,6 +1,18 @@
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, useRevealedText, useToast };
export const pluginReactNativeRuntime = {
Icon,
Modal,
ScrollView,
FlatList,
TextInput,
copyText,
useRevealedText,
useToast,
};
@@ -0,0 +1,25 @@
import { forwardRef } from "react";
import type {
ScrollView as NativeScrollView,
ScrollViewProps,
FlatListProps,
FlatList as NativeFlatList,
} from "react-native";
import type * as Shared from "@/components/ui/scroll-view";
// Plugin evaluation also runs before the native UI is ready. Load presentation
// dependencies at render time, as Modal does, rather than evaluating them on import.
export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
function ScrollView(props, ref) {
const { ScrollView: HostScrollView } =
require("../../components/ui/scroll-view") as typeof Shared;
return <HostScrollView {...props} ref={ref} />;
},
);
export const FlatList = forwardRef<NativeFlatList<unknown>, FlatListProps<unknown>>(
function FlatList(props, ref) {
const { FlatList: HostFlatList } = require("../../components/ui/scroll-view") as typeof Shared;
return <HostFlatList {...props} ref={ref} />;
},
) as typeof Shared.FlatList;
@@ -0,0 +1,12 @@
import { forwardRef, type ElementRef } from "react";
import type { TextInputProps } from "react-native";
import type { SheetAwareTextInput } from "@/components/ui/text-input/sheet-aware";
export const TextInput = forwardRef<ElementRef<typeof SheetAwareTextInput>, TextInputProps>(
function TextInput(props, ref) {
const { SheetAwareTextInput } =
require("../../components/ui/text-input/sheet-aware") as typeof import("../../components/ui/text-input/sheet-aware");
return <SheetAwareTextInput {...props} ref={ref} />;
},
);
+7
View File
@@ -65,6 +65,7 @@
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"react-native": "0.81.5",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
@@ -73,6 +74,12 @@
"@getpaseo/client": "0.7.2",
"@getpaseo/protocol": "0.7.2",
"react": "19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
}
}
+40 -1
View File
@@ -1,4 +1,22 @@
import type { ComponentType, FunctionComponent, ReactNode } 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,
} from "react-native";
import type { PluginIconProps } from "./contracts.js";
export interface ModalProps {
@@ -11,6 +29,12 @@ export interface ModalProps {
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> {
@@ -35,3 +59,18 @@ export declare function useToast(): ToastApi;
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
export type { PluginIconProps } from "./contracts.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>
>;
+26
View File
@@ -0,0 +1,26 @@
# Modal UI example
Install this directory as a local plugin, then open **Modal examples** from the sidebar or a workspace tab.
The examples demonstrate default and custom padding, a full-width body, author-owned ScrollView and
FlatList scrolling, horizontal tabs, and clipboard actions with a keyboard-aware input. See the
[host UI reference](../../public-docs/plugins/v0.8/reference.md#host-ui) for the API contract.
The browser regression installs this exact example in an isolated daemon:
```sh
cd packages/app
npx playwright test e2e/browser/plugin-modal-body.spec.ts --project=browser --workers=1
```
On Android, open each example and swipe through its content. In FlatList, use **Jump to last row**
before expanding the sheet; row 100 should be visible. Drag the handle to expand the sheet. In Form,
press **Copy text**, long-press the input, and choose **Paste**. The input should contain
“Copied from Paseo”. With the system keyboard enabled, focusing the input should keep it visible.
These captures show Android copy/paste and custom padding on browser and wide native layouts.
Android API 35 and Chromium were exercised; iOS and Electron were not tested.
| Android copy/paste | Browser custom padding | Wide Android custom padding |
| ---------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
| ![Android copy and OS paste](screenshots/android-copy-paste.png) | ![Browser custom padding](screenshots/web-custom-inset.png) | ![Wide Android custom padding](screenshots/android-wide-custom-inset.png) |
@@ -0,0 +1,205 @@
import type { PluginSurfaceProps } from "@getpaseo/plugin";
import {
Modal,
ScrollView,
FlatList,
TextInput,
copyText,
useToast,
} from "@getpaseo/plugin/react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Pressable,
Text,
View,
StyleSheet,
type StyleProp,
type ViewStyle,
type TextStyle,
type FlatList as NativeFlatList,
} from "react-native";
const modes = [
"Form",
"Custom inset",
"Full bleed",
"ScrollView",
"FlatList",
"Horizontal",
] as const;
type Mode = (typeof modes)[number];
const rows = Array.from({ length: 100 }, (_, index) => index + 1);
const tabs = ["Overview", "Configuration", "Activity", "Permissions", "System logs"];
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, gap: 12 },
button: { padding: 12, borderWidth: 1, borderRadius: 6 },
fill: { flex: 1, minHeight: 0 },
customInset: { padding: 8, paddingBottom: 40, gap: 16 },
fullBleed: { padding: 0, gap: 0 },
row: { height: 56, padding: 16, borderBottomWidth: 1 },
form: { gap: 16 },
input: { borderWidth: 1, padding: 12, borderRadius: 6 },
tabs: { flexGrow: 0, flexShrink: 0 },
tab: { padding: 18, minWidth: 160 },
});
export function ModalExamples({ theme }: Pick<PluginSurfaceProps, "theme">) {
const [mode, setMode] = useState<Mode | null>(null);
const [input, setInput] = useState("");
const [selected, setSelected] = useState("");
const list = useRef<NativeFlatList<number>>(null);
const toast = useToast();
const color = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
const body = useMemo(() => ({ backgroundColor: theme.colors.surface2 }), [theme]);
const button = useMemo(() => [styles.button, { borderColor: theme.colors.border }], [theme]);
const rowStyle = useMemo(() => [styles.row, { borderColor: theme.colors.border }], [theme]);
const inputStyle = useMemo(
() => [styles.input, color, { borderColor: theme.colors.border }],
[color, theme],
);
const copy = useCallback(async () => {
try {
await copyText("Copied from Paseo");
toast.show("Text copied", { variant: "success" });
} catch {
toast.error("Could not copy text. Select the text and use Copy.");
}
}, [toast]);
const row = useCallback(
(item: number) => {
return (
<ChoiceButton
key={item}
value={`Row ${item}`}
label={`Row ${item}`}
style={rowStyle}
textStyle={color}
onSelect={setSelected}
/>
);
},
[rowStyle, color],
);
const renderRow = useCallback(({ item }: { item: number }) => row(item), [row]);
const close = useCallback((open: boolean) => {
if (!open) setMode(null);
}, []);
const jumpToEnd = useCallback(() => list.current?.scrollToEnd({ animated: false }), []);
const screen = useMemo(() => [styles.screen, body], [body]);
let contentStyle: StyleProp<ViewStyle> = styles.fullBleed;
if (mode === "Form") contentStyle = undefined;
if (mode === "Custom inset") contentStyle = styles.customInset;
const form = (
<View style={styles.form} testID="modal-example-form">
<Text selectable style={color}>
Paseo clipboard example
</Text>
<Pressable accessibilityRole="button" style={button} onPress={copy}>
<Text style={color}>Copy text</Text>
</Pressable>
<TextInput
accessibilityLabel="Paste here"
placeholder="Paste here"
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
value={input}
onChangeText={setInput}
/>
<Text style={color}>Input: {input || "empty"}</Text>
</View>
);
return (
<View style={screen}>
<Text style={color}>Modal body examples</Text>
{modes.map((example) => (
<ChoiceButton<Mode>
key={example}
value={example}
label={`Open ${example}`}
style={button}
textStyle={color}
onSelect={setMode}
/>
))}
<Text style={color}>Selected: {selected || "none"}</Text>
<Modal title={`Modal example: ${mode ?? "Form"}`} open={mode !== null} onOpenChange={close}>
<Modal.Content
style={body}
contentContainerStyle={contentStyle}
scrollable={mode !== "ScrollView" && mode !== "FlatList"}
>
{mode === "Form" || mode === "Custom inset" ? form : null}
{mode === "Full bleed" ? rows.map(row) : null}
{mode === "ScrollView" ? (
<ScrollView style={styles.fill} keyboardShouldPersistTaps="handled">
{rows.map(row)}
</ScrollView>
) : null}
{mode === "FlatList" ? (
<>
<Pressable accessibilityRole="button" style={button} onPress={jumpToEnd}>
<Text style={color}>Jump to last row</Text>
</Pressable>
<FlatList
ref={list}
style={styles.fill}
data={rows}
keyExtractor={String}
renderItem={renderRow}
getItemLayout={getRowLayout}
/>
</>
) : null}
{mode === "Horizontal" ? (
<>
<ScrollView horizontal style={styles.tabs}>
{tabs.map((tab) => (
<ChoiceButton
key={tab}
value={tab}
label={tab}
style={styles.tab}
textStyle={color}
onSelect={setSelected}
/>
))}
</ScrollView>
{form}
{rows.slice(0, 20).map(row)}
</>
) : null}
</Modal.Content>
</Modal>
</View>
);
}
function getRowLayout(_: unknown, index: number) {
return { index, length: 56, offset: index * 56 };
}
function ChoiceButton<Value extends string>({
value,
label,
style,
textStyle,
onSelect,
}: {
value: Value;
label: string;
style: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>;
onSelect(value: Value): void;
}) {
const select = useCallback(() => onSelect(value), [onSelect, value]);
return (
<Pressable accessibilityRole="button" style={style} onPress={select}>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import { ModalExamples } from "./client/examples";
export default function contribute(plugin: PluginClientContext) {
plugin.addSurface("main", ModalExamples);
plugin.addSidebarItem({
id: "main",
title: "Modal examples",
icon: "PanelsTopLeft",
surface: "main",
});
plugin.addWorkspacePanel({
id: "examples",
title: "Modal examples",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: ModalExamples,
});
return () => {};
}
@@ -0,0 +1,3 @@
{
"id": "modal-ui-example"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+83 -3
View File
@@ -320,9 +320,23 @@ the `open` state.
`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. |
| Prop | Type | Default | Behavior |
| ----------------------- | ---------------------- | ------------------ | --------------------------------------------------------------------------------- |
| `children` | `ReactNode` | Required | Body content below the header. |
| `style` | `StyleProp<ViewStyle>` | — | Styles the full body viewport, including empty space. Use for backgrounds. |
| `contentContainerStyle` | `StyleProp<ViewStyle>` | Padding 24, gap 16 | Overrides the content layout. Set `padding: 0, gap: 0` for edge-to-edge rows. |
| `scrollable` | `boolean` | `true` | The host scrolls the body. Set `false` for a bounded body with your own scroller. |
A plain `Modal.Content` is already padded and scrollable. Avoid adding a second padded wrapper
unless you want another inset. Body styles leave the host header, drag handle, and dismissal controls
intact. The host reserves bottom safe-area space on compact native layouts; setting content padding
to zero removes the decorative inset, not that space. Keyboard clearance is handled separately.
With `scrollable={false}`, the body fills the available sheet height, and the centered dialog uses
85% of the available height. Use `flex: 1, minHeight: 0` on your list. In this mode the body scrolls
at every sheet height; drag the handle to resize or dismiss the sheet. Default scrolling dialogs stay
content-sized on wide layouts. Presentation follows window size, including narrow desktop windows
and wide tablets.
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.
@@ -330,6 +344,72 @@ the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open`
Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorkspace`, and
`useAgent` work inside them.
### Scrolling
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/react-native` 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.
Use one vertical scroll owner: either the default modal body, or your own list with
`scrollable={false}`. A fixed-height vertical list nested inside the default scrolling body can compete
with the sheet for gestures on Android. Horizontal scrolling can coexist with the host's vertical body.
```tsx
import { FlatList, Modal } from "@getpaseo/plugin/react-native";
import { Text } from "react-native";
// Inside your controlled Modal:
<Modal.Content
scrollable={false}
style={{ backgroundColor: theme.colors.surface1 }}
contentContainerStyle={{ padding: 0, gap: 0 }}
>
<FlatList
style={{ flex: 1, minHeight: 0 }}
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Text style={{ padding: 16, color: theme.colors.foreground }}>{item.title}</Text>
)}
/>
</Modal.Content>;
```
For horizontal tabs, place `<ScrollView horizontal style={{ flexGrow: 0 }}>…</ScrollView>` inside the
default `Modal.Content`. Keep the content's vertical scrolling on the host.
### Copy and paste
`copyText(text): Promise<void>` writes to the clipboard on the device running the app. Call it from a
user action and await it before reporting success. It rejects if the platform denies copying or the
clipboard is unavailable; browser permissions and secure-context requirements still apply.
```tsx
import { copyText, useToast } from "@getpaseo/plugin/react-native";
// Inside your component:
const toast = useToast();
async function copyResult() {
try {
await copyText(result);
toast.show("Copied", { variant: "success" });
} catch {
toast.error("Could not copy. Select the text and use Copy.");
}
}
```
Programmatic copying and native text selection are separate interactions. Use `<Text selectable>`
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/react-native` 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
API is needed for OS Paste. Avoid DOM clipboard code in native plugins and the deprecated
`Clipboard` export from `react-native`.
The runnable [modal UI example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/modal-ui)
contains a padded form, full-width rows, a virtualized list, horizontal tabs, and a copy/paste input.
### Toasts
`useToast()` returns two methods: