fix(app): close a plugin picker when its trigger moves to the overflow

The measuring slot keeps toolbar plugin items mounted, so a picker that
was open when the toolbar condensed lost its trigger but kept its popup,
which portals to the overlay root. Mark the slot's items inactive while
they are out of the row, the contract retained panels already use to
close their popups.
This commit is contained in:
Mohamed Boudra
2026-11-09 16:10:17 +01:00
parent 6de49ad32f
commit 635d80c149
3 changed files with 83 additions and 13 deletions
@@ -3,6 +3,7 @@ import {
expectToolbarNeverCondensedOrClipped,
recordToolbarFramesFromLoad,
withComposerControlsExample,
withPickerComposerItem,
withToggleAndWatcherComposerItems,
} from "../support/helpers/plugin-composer-items";
@@ -43,6 +44,17 @@ test("a toolbar without room moves a plugin toggle to the overflow sheet and bac
});
});
test("an open plugin picker closes when its trigger moves to the overflow", async ({ page }) => {
test.setTimeout(120_000);
await withPickerComposerItem(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.openInlinePicker();
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectPickerClosed();
});
});
test("the toolbar settles on one density without flipping as the window widens", async ({
page,
}) => {
@@ -90,6 +90,33 @@ export default function contribute(client) {
return () => {};
}`;
const PICKER_NAME = "Review state";
const PICKER_PLUGIN_SOURCE = `import { Select } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
const OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "ready", label: "Ready" },
];
function ReviewState() {
const [value, setValue] = useState("draft");
return (
<Select
title="${PICKER_NAME}"
icon="CircleDot"
value={value}
options={OPTIONS}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "review-state", Component: ReviewState });
return () => {};
}`;
function toolbar(page: Page) {
return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first();
}
@@ -136,18 +163,38 @@ function agentFeaturesButton(page: Page) {
return page.getByRole("button", { name: "Open agent features", exact: true });
}
interface ToolbarPluginComposer {
setWindowWidth(width: number): Promise<void>;
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
expectToggleInlineAtFullDensity(): Promise<void>;
expectToggleInOverflow(): Promise<void>;
closeOverflowSheet(): Promise<void>;
startRecordingDensity(): Promise<void>;
expectAtMostOneDensityChange(): Promise<void>;
openInlinePicker(): Promise<void>;
expectPickerClosed(): Promise<void>;
}
/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */
export async function withToggleAndWatcherComposerItems(
export function withToggleAndWatcherComposerItems(
page: Page,
run: (composer: {
setWindowWidth(width: number): Promise<void>;
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
expectToggleInlineAtFullDensity(): Promise<void>;
expectToggleInOverflow(): Promise<void>;
closeOverflowSheet(): Promise<void>;
startRecordingDensity(): Promise<void>;
expectAtMostOneDensityChange(): Promise<void>;
}) => Promise<void>,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, ONE_TOGGLE_PLUGIN_SOURCE, run);
}
/** Runs `run` in an agent's composer with one plugin `Select` in the toolbar. */
export function withPickerComposerItem(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, PICKER_PLUGIN_SOURCE, run);
}
async function withToolbarPlugin(
page: Page,
pluginSource: string,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" });
const agent = await workspace.client.createAgent({
@@ -165,7 +212,7 @@ export async function withToggleAndWatcherComposerItems(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: ONE_TOGGLE_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), ONE_TOGGLE_PLUGIN_SOURCE);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
@@ -229,6 +276,14 @@ export async function withToggleAndWatcherComposerItems(
});
expect(changes).toBeLessThanOrEqual(1);
}),
openInlinePicker: () =>
test.step("open the plugin picker from the toolbar", async () => {
await toolbar(page).getByRole("button", { name: PICKER_NAME, exact: true }).click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible();
}),
expectPickerClosed: () =>
test.step("the plugin picker has closed", () =>
expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0)),
});
} finally {
await client.removePlugin(ONE_TOGGLE_PLUGIN_ID);
@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { type LayoutChangeEvent, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { TOOLBAR_CONTROL_GAP } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import { PluginComposerItems } from "@/plugins/composer-items";
@@ -29,7 +30,7 @@ export function usePluginToolbarWidth(): {
* is an arbitrary component, so its width is known only from rendering it, and it is always the
* toolbar form that is measured: the width never depends on the density it decides, so moving the
* items to the overflow sheet cannot change the answer. Out of the row, the same node stays
* mounted and measured, unseen and out of reach.
* mounted and measured, unseen and out of reach, and inactive so an open picker closes with it.
*/
export function PluginToolbarSlot({
serverId,
@@ -54,7 +55,9 @@ export function PluginToolbarSlot({
importantForAccessibility={inline ? "auto" : "no-hide-descendants"}
aria-hidden={!inline}
>
<PluginComposerItems serverId={serverId} surface="toolbar" />
<RetainedPanelActivity active={inline}>
<PluginComposerItems serverId={serverId} surface="toolbar" />
</RetainedPanelActivity>
</View>
);
}