diff --git a/docs/plugins.md b/docs/plugins.md index 30d99a00a8..bf83669d55 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -587,11 +587,14 @@ the author API, selection, `DocumentFrame`, and the version requirement. The exa Header buttons and composer pills share the client-only descriptor and registration lifecycle in `packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons) owns the author API and placement rules. Keep presentation policy in this module so another -placement can reuse behavior without copying registration or action state. +placement can reuse behavior without copying registration or action state. The header overflow +and the compact workspace menu share one builder that turns buttons into menu rows and pages; the +workspace menu reaches it through `usePluginWorkspaceMenu`. Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state, -query, and toast providers inside the surface content, including overflow pages from different -plugins. Providers only around the trigger do not reach those bodies. +query, and toast providers inside the surface content. A host menu can hold buttons from several +installations, so each of its rows and pages resolves its own installation's runtime. Providers +only around the trigger do not reach those bodies. Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned `subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index d3503f56ed..fe4227a8c3 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -4,13 +4,14 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons"; test("plugin header and composer buttons share actions, menus, content, and updates", async ({ page, -}) => { - test.setTimeout(180_000); - await withButtonShowcase(page, async (buttons) => { +}, info) => { + test.setTimeout(240_000); + await withButtonShowcase(page, info, async (buttons) => { await buttons.verifyObservationLifetime(); await buttons.openWideMenusAndPopovers(); await buttons.runAndUpdateActions(); await buttons.openCompactSheets(); + await buttons.useCompactWorkspaceMenu(); await buttons.hideAndUnloadOpenButtons(); }); }); diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index f3279ce7ef..2418f284d2 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -1,11 +1,17 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Locator } from "@playwright/test"; +import type { Locator, TestInfo } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { buildAgentRoute } from "./mock-agent"; import { openCommandCenter } from "./command-center"; +import { + closeCompactWorkspaceMenu, + compactWorkspaceMenu, + goBackInMenu, + openCompactWorkspaceMenu, +} from "./compact-workspace-scripts"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { seedWorkspace } from "./seed-client"; import { pluginRequirements } from "./plugin-fixture"; @@ -60,7 +66,7 @@ export default function contribute(client) { let finishReview = () => {}; let attempts = 0; const action = { kind: "action", async onPress() { - if (++attempts === 1) throw new Error("Deployment failed. Try again."); + if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again."); await new Promise((resolve) => { finish = resolve; }); deploy.update({ label: "Deployed" }); } }; @@ -165,6 +171,13 @@ async function command(page: Page, title: string) { await expect(panel).not.toBeVisible(); } +/** The command center opens from the sidebar, which compact layouts keep closed. */ +async function commandFromCompact(page: Page, title: string) { + await page.setViewportSize(WIDE); + await command(page, title); + await page.setViewportSize(COMPACT); +} + async function expectSurfaceVisible( page: Page, subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }), @@ -176,6 +189,61 @@ async function expectDetails(page: Page) { await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport(); } +function workspaceMenuRow(page: Page, title: string): Locator { + return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true }); +} + +async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) { + const rows = compactWorkspaceMenu(page).getByRole("menuitem"); + for (const [index, title] of titles.entries()) { + await expect(rows.nth(index)).toHaveAccessibleName(title); + } +} + +async function chooseInWorkspaceMenu(page: Page, title: string) { + await workspaceMenuRow(page, title).click(); +} + +async function expectWorkspaceMenuClosed(page: Page) { + await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 }); +} + +async function expectCompactDetails(page: Page) { + const menu = compactWorkspaceMenu(page); + await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport(); + await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible(); +} + +/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */ +async function expectStill(subject: Locator) { + let previous: Buffer | null = null; + await expect + .poll( + async () => { + const current = await subject.screenshot(); + const still = previous !== null && current.equals(previous); + previous = current; + return still; + }, + { intervals: [150] }, + ) + .toBe(true); +} + +/** Attaches the screen once per colour scheme, for review alongside the run. */ +async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) { + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expectStill(subject); + const file = info.outputPath(`${name}-${colorScheme}.png`); + await info.attach(`${name}-${colorScheme}`, { + body: await page.screenshot({ path: file }), + contentType: "image/png", + }); + } + await page.emulateMedia({ colorScheme: "light" }); +} + async function expectHeaderTextTruncated(page: Page) { const title = page.getByTestId("workspace-header-title"); const subtitle = page.getByTestId("workspace-header-subtitle"); @@ -204,10 +272,12 @@ async function expectHeaderTextTruncated(page: Page) { export async function withButtonShowcase( page: Page, + info: TestInfo, run: (buttons: { openWideMenusAndPopovers(): Promise; runAndUpdateActions(): Promise; openCompactSheets(): Promise; + useCompactWorkspaceMenu(): Promise; hideAndUnloadOpenButtons(): Promise; verifyObservationLifetime(): Promise; }) => Promise, @@ -284,6 +354,12 @@ export async function withButtonShowcase( .getByTestId("plugin-button-chevron"), ).toHaveCount(0); await expectSurfaceVisible(page); + await captureSchemes( + page, + info, + "desktop-wide-header", + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), + ); await press(page, "Header checks"); await expect( page.getByRole("menuitem", { name: "Run checks", exact: true }), @@ -303,6 +379,12 @@ export async function withButtonShowcase( await expect( page.getByRole("menuitem", { name: "Production locked", exact: true }), ).toBeDisabled(); + await captureSchemes( + page, + info, + "desktop-overflow", + page.getByRole("menuitem", { name: "Production locked", exact: true }), + ); await expectSurfaceVisible( page, page.getByRole("menuitem", { name: "Production locked", exact: true }), @@ -372,7 +454,19 @@ export async function withButtonShowcase( await expectSurfaceVisible(page); await command(page, "Enable example buttons"); await command(page, "Use icon-only header"); + await expect( + page.getByRole("button", { name: "Deploy application", exact: true }), + ).toHaveText(""); await expectSurfaceVisible(page); + await expect( + page.getByText("Deployment failed. Try again.", { exact: true }), + ).toHaveCount(0); + await captureSchemes( + page, + info, + "desktop-icon-only-header", + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), + ); await command(page, "Hide review button"); await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount( 0, @@ -383,7 +477,7 @@ export async function withButtonShowcase( ); }), openCompactSheets: () => - test.step("compact header omits plugin actions while composer triggers open sheets", async () => { + test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => { await page.setViewportSize(COMPACT); await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); await expect( @@ -422,6 +516,98 @@ export async function withButtonShowcase( ); await choose(page, "Run checks"); }), + useCompactWorkspaceMenu: () => + test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => { + await openCompactWorkspaceMenu(page); + await expectWorkspaceMenuRows(page, [ + "Deploy application", + "Header checks", + "Header status", + "Open deployment logs", + "Production locked", + "Copy workspace path", + ]); + await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled(); + // Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles. + await expect(workspaceMenuRow(page, "Deploy application")).toHaveText( + "Deploy application", + ); + await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText( + "Open deployment logs", + ); + await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page)); + + await showcase.setTitle("Compact checks"); + await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0); + await showcase.setTitle("Button showcase"); + await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible(); + + await chooseInWorkspaceMenu(page, "Deploy application"); + await expectWorkspaceMenuClosed(page); + await expect( + page.getByText("Deployment failed. Try again.", { exact: true }), + ).toBeVisible(); + await openCompactWorkspaceMenu(page); + await chooseInWorkspaceMenu(page, "Deploy application"); + await expectWorkspaceMenuClosed(page); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled(); + await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page)); + await closeCompactWorkspaceMenu(page); + await commandFromCompact(page, "Finish deployment"); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled(); + await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page)); + + await chooseInWorkspaceMenu(page, "Header checks"); + await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]); + await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page)); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header status")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Header checks"); + await chooseInWorkspaceMenu(page, "Environment"); + await expectWorkspaceMenuRows(page, ["Staging", "Production"]); + await expect(workspaceMenuRow(page, "Production")).toBeDisabled(); + await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page)); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Run checks")).toBeVisible(); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header checks")).toBeVisible(); + + await chooseInWorkspaceMenu(page, "Header checks"); + await chooseInWorkspaceMenu(page, "Deployment details"); + await expectCompactDetails(page); + await captureSchemes( + page, + info, + "compact-deployment-details", + compactWorkspaceMenu(page), + ); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Run checks")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Deployment details"); + await expectCompactDetails(page); + await press(page, "Close deployment details"); + await expectWorkspaceMenuClosed(page); + + await openCompactWorkspaceMenu(page); + await chooseInWorkspaceMenu(page, "Header status"); + await expectCompactDetails(page); + await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page)); + await press(page, "Fail button content"); + await expect( + compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", { + exact: true, + }), + ).toBeVisible(); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header status")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Open deployment logs"); + await expectCompactDetails(page); + await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page)); + await press(page, "Close deployment details"); + await expectWorkspaceMenuClosed(page); + }), hideAndUnloadOpenButtons: () => test.step("hiding an open button and unloading a plugin remove its surfaces", async () => { await page.setViewportSize(WIDE); @@ -454,6 +640,12 @@ export async function withButtonShowcase( 0, ); await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0); + await page.setViewportSize(COMPACT); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0); + await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0); + await closeCompactWorkspaceMenu(page); + await page.setViewportSize(WIDE); await showcase.setTitle("Healthy app after plugin unload"); await expect(page.getByTestId("workspace-header-title")).toHaveText( "Healthy app after plugin unload", diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 2cd3e1bd0f..3db4381597 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -4,7 +4,13 @@ import type { } from "./validation"; import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; -import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; +import { + useCallback, + useMemo, + useSyncExternalStore, + type ReactElement, + type ReactNode, +} from "react"; import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native"; @@ -180,7 +186,7 @@ function ButtonBody({ behavior={item.behavior} disabled={view.entry.pending || view.entry.button.disabled || item.disabled === true} path={itemPath} - pageId={buttonPageId(view, itemPath)} + pageId={buttonPageId(view.entry.key, itemPath)} /> ); }); @@ -204,14 +210,19 @@ function ButtonSurfaceBody({ ); } -function buttonPageId(view: ButtonView, path: readonly string[]): string { - return [view.entry.key, ...path].join("/"); +// Prefixed so plugin pages can share a surface with the host's own pages. +function buttonPageId(key: number, path: readonly string[]): string { + return ["plugin-button", key, ...path].join("/"); } +type RenderButtonPage = (behavior: PluginButtonBehavior, path: readonly string[]) => ReactNode; + +/** A page for every menu and popover item below `behavior`, depth first. */ function buttonPages( - view: ButtonView, + key: number, behavior: PluginButtonBehavior, - path: readonly string[] = [], + path: readonly string[], + render: RenderButtonPage, ): MenuPageDefinition[] { if (behavior.kind !== "menu") return []; return behavior.items.flatMap((item) => { @@ -220,12 +231,12 @@ function buttonPages( const itemPath = [...path, item.id]; return [ { - id: buttonPageId(view, itemPath), + id: buttonPageId(key, itemPath), title: item.title, hoverIntent: item.behavior.kind === "menu", - content: , + content: render(item.behavior, itemPath), }, - ...buttonPages(view, item.behavior, itemPath), + ...buttonPages(key, item.behavior, itemPath, render), ]; }); } @@ -304,7 +315,13 @@ function ButtonControl({ view }: { view: ButtonView }) { {contents} ); - const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]); + const pages = useMemo( + () => + buttonPages(entry.key, button.behavior, ROOT_PATH, (behavior, path) => ( + + )), + [view, entry.key, button.behavior], + ); return ( @@ -424,82 +441,112 @@ function PluginButtonHost({ const ThemedPluginButton = withUnistyles(PluginButtonHost); -// Overflow rows keep their own plugin runtime; a menu can contain several installations. -function OverflowButton({ view }: { view: ButtonView }) { - const { button } = view.entry; - return ( - +/** + * Each row and page of a shared menu resolves its own installation's runtime: one menu can hold + * buttons from several plugins, and none of them may run through another's host client. + */ +function useMenuButtonView(entry: RegisteredPluginButton, theme: PluginTheme): ButtonView | null { + const client = useHostRuntimeClient(entry.installation.serverId); + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const serverId = entry.installation.serverId; + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + return useMemo( + () => createButtonView({ entry, client, toast, compact, hostLabel, theme }), + [entry, client, toast, compact, hostLabel, theme], ); } -function OverflowPages({ - entries, - compact, - hostLabel, +function MenuButtonRow({ entry, theme }: { entry: RegisteredPluginButton; theme: PluginTheme }) { + const view = useMenuButtonView(entry, theme); + if (!view) return null; + const { button } = entry; + return ( + + + + + + ); +} + +function MenuButtonPage({ + entry, + behavior, + path, theme, }: { - entries: readonly RegisteredPluginButton[]; - compact: boolean; - hostLabel: string; + entry: RegisteredPluginButton; + behavior: PluginButtonBehavior; + path: readonly string[]; theme: PluginTheme; }) { - // The header belongs to one host, but each button keeps its installation's query cache and RPCs. - const client = useHostRuntimeClient(entries[0].installation.serverId); - const toast = useToast(); - const menuContent = useMemo(() => { - const pages: MenuPageDefinition[] = []; - const rows: ReactNode[] = []; - for (const entry of entries) { - const view = createButtonView({ entry, client, toast, compact, hostLabel, theme }); - if (!view) continue; - rows.push( - - - - - , - ); - if (entry.button.behavior.kind === "action") continue; - pages.push( - { - id: buttonPageId(view, []), - title: entry.button.title, - hoverIntent: false, - content: ( - - ), - }, - ...buttonPages(view, entry.button.behavior), - ); - } - return { pages, rows }; - }, [entries, client, toast, theme, hostLabel, compact]); + const view = useMenuButtonView(entry, theme); + if (!view) return null; + return ; +} + +const ThemedMenuButtonRow = withUnistyles(MenuButtonRow); +const ThemedMenuButtonPage = withUnistyles(MenuButtonPage); + +export interface PluginButtonsMenu { + /** One row per button, in registration order. Render them on the menu's root page. */ + rows: ReactElement[]; + /** The pages those rows lead to. Pass them to the menu's surface. */ + pages: MenuPageDefinition[]; +} + +function buttonsMenu(entries: readonly RegisteredPluginButton[]): PluginButtonsMenu { + const rows = entries.map((entry) => ( + + )); + const pages = entries.flatMap((entry): MenuPageDefinition[] => { + const { behavior } = entry.button; + if (behavior.kind === "action") return []; + const render: RenderButtonPage = (pageBehavior, path) => ( + + ); + return [ + { + id: buttonPageId(entry.key, ROOT_PATH), + title: entry.button.title, + hoverIntent: false, + content: render(behavior, ROOT_PATH), + }, + ...buttonPages(entry.key, behavior, ROOT_PATH, render), + ]; + }); + return { rows, pages }; +} + +function OverflowSurface({ entries }: { entries: readonly RegisteredPluginButton[] }) { const { t } = useTranslation(); + const menu = useMemo(() => buttonsMenu(entries), [entries]); return ( - {menuContent.rows} + {menu.rows} ); } -const ThemedOverflowPages = withUnistyles(OverflowPages); - function useButtons(serverId: string, workspaceId: string, agentId: string | null) { const entries = useSyncExternalStore( pluginButtonStore.subscribe, @@ -545,6 +592,15 @@ export function PluginComposerPills({ )); } +/** + * A workspace's header buttons as rows and pages of a host menu. Compact headers have no room for + * the buttons themselves, so the workspace menu carries them instead. + */ +export function usePluginWorkspaceMenu(serverId: string, workspaceId: string): PluginButtonsMenu { + const entries = useButtons(serverId, workspaceId, null); + return useMemo(() => buttonsMenu(entries), [entries]); +} + export function PluginHeaderButtons({ serverId, workspaceId, @@ -590,12 +646,7 @@ export function PluginHeaderButtons({ > - + ) : null} diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index e221f4208f..ac3500e7e9 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -7,4 +7,5 @@ export { PluginComposerPills, PluginHeaderButtons, useHasPluginComposerPills, + usePluginWorkspaceMenu, } from "./buttons/view"; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 74a95c7548..b8f4739cfb 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -31,6 +31,7 @@ import { useWorkspaceScriptsMenuPages, type UseWorkspaceScriptsMenuPagesInput, } from "@/screens/workspace/scripts"; +import { usePluginWorkspaceMenu } from "@/plugins"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); @@ -206,6 +207,7 @@ function HeaderMenuProfileItem({ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { normalizedServerId: string; + normalizedWorkspaceId: string; scripts: UseWorkspaceScriptsMenuPagesInput; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; @@ -218,10 +220,12 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace * the rarer launches stay here alongside the workspace actions. * * The header has no room for a scripts button either, so scripts are a page of this menu, and the - * ⋯ carries a dot while a running service is unhealthy. + * ⋯ carries a dot while a running service is unhealthy. Plugin header buttons are rows here for the + * same reason. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, + normalizedWorkspaceId, scripts, showCreateBrowserTab, createTerminalDisabled, @@ -242,6 +246,11 @@ export function WorkspaceHeaderMenuMobile({ router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); }, [normalizedServerId, router]); const scriptsMenu = useWorkspaceScriptsMenuPages(scripts); + const pluginMenu = usePluginWorkspaceMenu(normalizedServerId, normalizedWorkspaceId); + const pages = useMemo( + () => [...scriptsMenu.pages, ...pluginMenu.pages], + [scriptsMenu.pages, pluginMenu.pages], + ); return ( @@ -266,10 +275,12 @@ export function WorkspaceHeaderMenuMobile({ align="start" width={220} testID="workspace-header-menu" - pages={scriptsMenu.pages} + pages={pages} sheetTitle={t("workspace.header.actions.workspaceActions")} > {scriptsMenu.trigger} + {pluginMenu.rows} + {pluginMenu.rows.length > 0 ? : null} {showCreateBrowserTab ? (