From 82687ec95bd9e54e272dd86ab329fc36cf30ce7f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 09:08:11 +0700 Subject: [PATCH] Keep plugin composer items in the toolbar when they fit (#9209) * fix(app): keep plugin composer items in the toolbar when they fit The density budget guessed a plugin item's width from a fixed label and counted every registered item, so an icon toggle or an item rendering nothing pushed the toolbar into its overflow state with room to spare. An item is an arbitrary component, so its width is only known by rendering it. Measure the toolbar form in an always-mounted slot; the width never depends on the density it decides, so moving items to the overflow cannot flip the answer. Sheet rows truncated short values such as "Full access" because the value's 45% cap resolved against a content-width row. Fill the row so the cap resolves against the sheet's width. * 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. * test(app): allow the Updating messages toast when the daemon answers slowly A freshness check unanswered after 500ms reports Updating messages by design, but the resume spec asserted no toast regardless of how long the daemon took. On a loaded runner a cold reload's answer took 512ms, so the toast was correct and the spec failed. Assert no toast only when the answer came back promptly, using answer times the gate records. * test(app): check the Updating messages toast the same way on every run The previous repair skipped the no-toast assertion whenever the daemon took 300ms or more to answer, so a slow run proved nothing. The toast is correct once a check has gone unanswered for the quiet window, so the spec now always asserts that the toast did not appear sooner than that after the question was sent, timed in the page. The delayed-answer path gets its own test that holds the daemon's answer. --- .../e2e/browser/agent-timeline-resume.spec.ts | 21 ++ .../e2e/browser/plugin-composer-items.spec.ts | 71 +++++- .../support/helpers/plugin-composer-items.ts | 238 +++++++++++++++++- .../e2e/support/helpers/timeline-resume.ts | 116 ++++++--- .../composer-control.browser.test.tsx | 25 ++ .../src/composer/agent-controls/control.tsx | 1 + .../app/src/composer/agent-controls/index.tsx | 47 ++-- .../composer/agent-controls/layout.test.ts | 109 +++++--- .../app/src/composer/agent-controls/layout.ts | 22 +- .../agent-controls/plugin-toolbar-slot.tsx | 82 ++++++ .../app/src/plugins/composer-items/index.tsx | 2 +- public-docs/plugins/reference.md | 7 +- 12 files changed, 640 insertions(+), 101 deletions(-) create mode 100644 packages/app/src/composer/agent-controls/plugin-toolbar-slot.tsx diff --git a/packages/app/e2e/browser/agent-timeline-resume.spec.ts b/packages/app/e2e/browser/agent-timeline-resume.spec.ts index 8d164e954a..06c373bc00 100644 --- a/packages/app/e2e/browser/agent-timeline-resume.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-resume.spec.ts @@ -11,6 +11,7 @@ import { seedLongMockAgentTimeline, } from "../support/helpers/timeline-pagination"; import { + answerFreshnessCheck, commitTimelineTurnsWhileDisconnected, disconnectViewedTimeline, expectResumeAnsweredWithoutFetch, @@ -21,6 +22,7 @@ import { restoreUnchangedViewedTimeline, restoreViewedTimelineHoldingCatchUp, restoreViewedTimelineWithHeldResponse, + returnWithFreshnessCheckUnanswered, waitForResumeAnswer, reopenAppWatchingForUpdatingToast, watchForUpdatingToast, @@ -101,6 +103,25 @@ test.describe("Agent timeline resume", () => { } }); + test("a check the daemon has not answered reports the update until it does", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 3 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + const requests = rememberTimelineRequestCounts(gate); + + await returnWithFreshnessCheckUnanswered(page, gate); + await answerFreshnessCheck(page, gate); + + expectResumeAnsweredWithoutFetch(gate, requests); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + test("returning to a changed chat reports the update and shows the new messages", async ({ page, }) => { diff --git a/packages/app/e2e/browser/plugin-composer-items.spec.ts b/packages/app/e2e/browser/plugin-composer-items.spec.ts index 19218c55d4..2c669105ae 100644 --- a/packages/app/e2e/browser/plugin-composer-items.spec.ts +++ b/packages/app/e2e/browser/plugin-composer-items.spec.ts @@ -1,5 +1,11 @@ import { test } from "../support/fixtures"; -import { withComposerControlsExample } from "../support/helpers/plugin-composer-items"; +import { + expectToolbarNeverCondensedOrClipped, + recordToolbarFramesFromLoad, + withComposerControlsExample, + withPickerComposerItem, + withToggleAndWatcherComposerItems, +} from "../support/helpers/plugin-composer-items"; test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => { test.setTimeout(120_000); @@ -10,3 +16,66 @@ test("a narrow composer's overflow sheet shows a plugin item's row", async ({ pa await composer.expectFeatureRow("Insert checklist"); }); }); + +test("a toolbar with room keeps a plugin toggle inline at full density", async ({ page }) => { + test.setTimeout(120_000); + await withToggleAndWatcherComposerItems(page, async (composer) => { + // Wider than what renders, too narrow for two 125-wide "Plugin item" estimates. + await composer.setWindowWidth(1000); + await composer.expectToolbarWidthBetween(470, 620); + await composer.expectToggleInlineAtFullDensity(); + }); +}); + +test("a toolbar without room moves a plugin toggle to the overflow sheet and back", async ({ + page, +}) => { + test.setTimeout(120_000); + await withToggleAndWatcherComposerItems(page, async (composer) => { + await composer.setWindowWidth(1000); + await composer.expectToggleInlineAtFullDensity(); + // Too narrow for full labels, wide enough that the controls have not folded away. + await composer.setWindowWidth(880); + await composer.expectToolbarWidthBetween(300, 395); + await composer.expectToggleInOverflow(); + await composer.closeOverflowSheet(); + await composer.setWindowWidth(1000); + await composer.expectToggleInlineAtFullDensity(); + }); +}); + +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, +}) => { + test.setTimeout(120_000); + await withToggleAndWatcherComposerItems(page, async (composer) => { + await composer.setWindowWidth(880); + await composer.expectToolbarWidthBetween(300, 395); + await composer.startRecordingDensity(); + for (let width = 880; width <= 1000; width += 8) await composer.setWindowWidth(width); + await composer.expectAtMostOneDensityChange(); + }); +}); + +test("a toolbar with room never paints condensed or clipped while the plugin loads", async ({ + page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1000, height: 800 }); + await recordToolbarFramesFromLoad(page); + await withToggleAndWatcherComposerItems(page, async (composer) => { + await composer.expectToggleInlineAtFullDensity(); + await expectToolbarNeverCondensedOrClipped(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-composer-items.ts b/packages/app/e2e/support/helpers/plugin-composer-items.ts index bac676ab38..678c9544da 100644 --- a/packages/app/e2e/support/helpers/plugin-composer-items.ts +++ b/packages/app/e2e/support/helpers/plugin-composer-items.ts @@ -1,8 +1,11 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { openAgentRoute } from "./mock-agent"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; -import { copyPluginExample } from "./plugin-fixture"; +import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { seedWorkspace } from "./seed-client"; const PLUGIN_ID = "composer-controls-example"; @@ -57,3 +60,236 @@ export async function withComposerControlsExample( await workspace.cleanup(); } } + +const ONE_TOGGLE_PLUGIN_ID = "composer-toggle-and-watcher-e2e"; +const ONE_TOGGLE_NAME = "Sandbox flag"; +const ONE_TOGGLE_PLUGIN_SOURCE = `import { useComposer } from "@getpaseo/plugin/client"; +import { Toggle } from "@getpaseo/plugin/client/ui"; +import { useState } from "react"; + +function PromptWatcher() { + useComposer(); + return null; +} + +function SandboxFlag() { + const [value, setValue] = useState(false); + return ( + setValue(next)} + /> + ); +} + +export default function contribute(client) { + client.addComposerItem({ id: "prompt-watcher", Component: PromptWatcher }); + client.addComposerItem({ id: "sandbox-flag", Component: SandboxFlag }); + 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 ( +