mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
fix(app): list plugin header buttons in the compact workspace menu
The compact header rewrite left plugin header buttons with no home, so phones and narrow windows had no way to reach them. They are now rows of the compact workspace menu, with menus and popovers as pages of its sheet, the way workspace scripts moved there. The header overflow and the workspace menu share one row/page builder. Each row and page resolves its own plugin installation's runtime: the old overflow routed every row through the first entry's host client. Closes #7601
This commit is contained in:
+6
-3
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
verifyObservationLifetime(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <ButtonSurfaceBody view={view} behavior={item.behavior} path={itemPath} />,
|
||||
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}
|
||||
</Pressable>
|
||||
);
|
||||
const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]);
|
||||
const pages = useMemo(
|
||||
() =>
|
||||
buttonPages(entry.key, button.behavior, ROOT_PATH, (behavior, path) => (
|
||||
<ButtonSurfaceBody view={view} behavior={behavior} path={path} />
|
||||
)),
|
||||
[view, entry.key, button.behavior],
|
||||
);
|
||||
return (
|
||||
<MenuRoot compactMode="sheet" open={entry.open} onOpenChange={setOpen}>
|
||||
<Tooltip enabledOnMobile={false}>
|
||||
@@ -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 (
|
||||
<ButtonMenuItem
|
||||
view={view}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
behavior={button.behavior}
|
||||
disabled={button.disabled || view.entry.pending}
|
||||
path={ROOT_PATH}
|
||||
pageId={buttonPageId(view, [])}
|
||||
/>
|
||||
/**
|
||||
* 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 (
|
||||
<SurfaceErrorBoundary installation={entry.installation} Surface={button.icon}>
|
||||
<ButtonEnvironment view={view}>
|
||||
<ButtonMenuItem
|
||||
view={view}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
behavior={button.behavior}
|
||||
disabled={button.disabled || entry.pending}
|
||||
path={ROOT_PATH}
|
||||
pageId={buttonPageId(entry.key, ROOT_PATH)}
|
||||
/>
|
||||
</ButtonEnvironment>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<SurfaceErrorBoundary
|
||||
key={entry.key}
|
||||
installation={entry.installation}
|
||||
Surface={entry.button.icon}
|
||||
>
|
||||
<ButtonEnvironment view={view}>
|
||||
<OverflowButton view={view} />
|
||||
</ButtonEnvironment>
|
||||
</SurfaceErrorBoundary>,
|
||||
);
|
||||
if (entry.button.behavior.kind === "action") continue;
|
||||
pages.push(
|
||||
{
|
||||
id: buttonPageId(view, []),
|
||||
title: entry.button.title,
|
||||
hoverIntent: false,
|
||||
content: (
|
||||
<ButtonSurfaceBody view={view} behavior={entry.button.behavior} path={ROOT_PATH} />
|
||||
),
|
||||
},
|
||||
...buttonPages(view, entry.button.behavior),
|
||||
);
|
||||
}
|
||||
return { pages, rows };
|
||||
}, [entries, client, toast, theme, hostLabel, compact]);
|
||||
const view = useMenuButtonView(entry, theme);
|
||||
if (!view) return null;
|
||||
return <ButtonSurfaceBody view={view} behavior={behavior} path={path} />;
|
||||
}
|
||||
|
||||
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) => (
|
||||
<ThemedMenuButtonRow key={entry.key} entry={entry} uniProps={pluginThemeMapping} />
|
||||
));
|
||||
const pages = entries.flatMap((entry): MenuPageDefinition[] => {
|
||||
const { behavior } = entry.button;
|
||||
if (behavior.kind === "action") return [];
|
||||
const render: RenderButtonPage = (pageBehavior, path) => (
|
||||
<ThemedMenuButtonPage
|
||||
entry={entry}
|
||||
behavior={pageBehavior}
|
||||
path={path}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<PluginPopoverSurface
|
||||
sheetTitle={t("workspace.git.actions.moreActions")}
|
||||
align="end"
|
||||
pages={menuContent.pages}
|
||||
pages={menu.pages}
|
||||
>
|
||||
{menuContent.rows}
|
||||
{menu.rows}
|
||||
</PluginPopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
>
|
||||
<ThemedMoreIcon size={16} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<ThemedOverflowPages
|
||||
entries={overflow}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
<OverflowSurface entries={overflow} />
|
||||
</MenuRoot>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -7,4 +7,5 @@ export {
|
||||
PluginComposerPills,
|
||||
PluginHeaderButtons,
|
||||
useHasPluginComposerPills,
|
||||
usePluginWorkspaceMenu,
|
||||
} from "./buttons/view";
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -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 ? <DropdownMenuSeparator /> : null}
|
||||
{showCreateBrowserTab ? (
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
|
||||
@@ -3406,6 +3406,7 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
scripts={compactHeaderScripts}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
|
||||
@@ -2192,8 +2192,10 @@ review.remove();
|
||||
```
|
||||
|
||||
Omit `label` for an icon-only header button. Menus and popovers show a chevron on wide layouts.
|
||||
Compact header buttons use icons without labels or chevrons. Paseo moves excess contributions
|
||||
into a shared overflow menu. Placement and overflow are host decisions.
|
||||
Paseo moves excess contributions into a shared overflow menu. On phones and narrow windows the
|
||||
header has no room for plugin buttons, so each one is a row of the workspace menu (`···`), with its
|
||||
icon and title; its menus and popovers open as pages of that sheet. Placement and overflow are host
|
||||
decisions.
|
||||
|
||||
## Composer pills
|
||||
|
||||
|
||||
@@ -1888,8 +1888,9 @@ review.update({ visible: true });
|
||||
review.remove();
|
||||
```
|
||||
|
||||
省略 `label` 即为只有图标的标题栏按钮。在宽布局中,菜单和弹出框会显示一个 V 形箭头。紧凑布局中的标题栏
|
||||
按钮只使用图标,没有标签和箭头。Paseo 会把多出来的贡献移入一个共享的溢出菜单。位置和溢出由宿主决定。
|
||||
省略 `label` 即为只有图标的标题栏按钮。在宽布局中,菜单和弹出框会显示一个 V 形箭头。Paseo 会把多出来的
|
||||
贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区
|
||||
菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。
|
||||
|
||||
## Composer 胶囊 {#composer-pills}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user