diff --git a/packages/app/e2e/browser/plugin-composer-items.spec.ts b/packages/app/e2e/browser/plugin-composer-items.spec.ts index 19218c55d4..6cbb6d0244 100644 --- a/packages/app/e2e/browser/plugin-composer-items.spec.ts +++ b/packages/app/e2e/browser/plugin-composer-items.spec.ts @@ -1,5 +1,10 @@ import { test } from "../support/fixtures"; -import { withComposerControlsExample } from "../support/helpers/plugin-composer-items"; +import { + expectToolbarNeverCondensedOrClipped, + recordToolbarFramesFromLoad, + withComposerControlsExample, + 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 +15,55 @@ 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("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..f262c3a91b 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,181 @@ 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 () => {}; +}`; + +function toolbar(page: Page) { + return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first(); +} + +interface ToolbarFrame { + overflowButton: boolean; + clipped: boolean; +} + +declare global { + interface Window { + __pluginToolbarFrames?: ToolbarFrame[]; + } +} + +/** From the first frame on, records whether the toolbar condensed or clipped. Call before loading. */ +export async function recordToolbarFramesFromLoad(page: Page): Promise { + await page.addInitScript(() => { + const frames: ToolbarFrame[] = []; + window.__pluginToolbarFrames = frames; + const tick = () => { + const row = Array.from( + document.querySelectorAll('[data-testid="agent-controls-toolbar"]'), + ).find((node) => node.getClientRects().length > 0); + if (row) { + frames.push({ + overflowButton: row.querySelector('[data-testid="agent-controls-features"]') !== null, + clipped: row.scrollWidth > row.clientWidth, + }); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +export async function expectToolbarNeverCondensedOrClipped(page: Page): Promise { + const frames = await page.evaluate(() => window.__pluginToolbarFrames ?? []); + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.overflowButton || frame.clipped)).toEqual([]); +} + +function agentFeaturesButton(page: Page) { + return page.getByRole("button", { name: "Open agent features", exact: true }); +} + +/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */ +export async function withToggleAndWatcherComposerItems( + page: Page, + run: (composer: { + setWindowWidth(width: number): Promise; + expectToolbarWidthBetween(min: number, max: number): Promise; + expectToggleInlineAtFullDensity(): Promise; + expectToggleInOverflow(): Promise; + closeOverflowSheet(): Promise; + startRecordingDensity(): Promise; + expectAtMostOneDensityChange(): Promise; + }) => Promise, +) { + const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" }); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Measured items agent", + model: "ten-second-stream", + modeId: "load-test", + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-measured-items-")); + await writeFile( + 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); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await gotoAppShell(page); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await expect(toolbar(page)).toBeVisible({ timeout: 30_000 }); + await run({ + setWindowWidth: (width) => + test.step(`set the window to ${width} wide`, () => + page.setViewportSize({ width, height: 800 })), + expectToolbarWidthBetween: (min, max) => + test.step(`the toolbar is between ${min} and ${max} wide`, async () => { + await expect + .poll(async () => (await toolbar(page).boundingBox())?.width ?? 0) + .toBeGreaterThanOrEqual(min); + expect((await toolbar(page).boundingBox())?.width ?? 0).toBeLessThanOrEqual(max); + }), + expectToggleInlineAtFullDensity: () => + test.step("the toggle is inline and the toolbar keeps its labels", async () => { + const toggle = toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }); + await expect(toggle).toBeVisible(); + await expect(agentFeaturesButton(page)).toHaveCount(0); + await expect(toolbar(page).getByTestId("agent-thinking-selector")).toHaveText("Low"); + const row = await toolbar(page).boundingBox(); + const box = await toggle.boundingBox(); + expect(box && row && box.x + box.width <= row.x + row.width).toBe(true); + }), + expectToggleInOverflow: () => + test.step("the toggle has moved to the overflow sheet", async () => { + await expect( + toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }), + ).toHaveCount(0); + await agentFeaturesButton(page).click(); + await expect( + page.getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }), + ).toBeVisible(); + }), + closeOverflowSheet: () => + test.step("close the overflow sheet", async () => { + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-features-sheet")).toHaveCount(0); + }), + startRecordingDensity: () => + test.step("record which density each frame paints", () => + page.evaluate(() => { + const frames: boolean[] = []; + const paint = () => { + frames.push( + document.querySelector('[data-testid="agent-controls-features"]') !== null, + ); + window.requestAnimationFrame(paint); + }; + window.requestAnimationFrame(paint); + Object.assign(window, { __densityFrames: frames }); + })), + expectAtMostOneDensityChange: () => + test.step("the density changed once at most", async () => { + const changes = await page.evaluate(() => { + const frames = (window as unknown as { __densityFrames: boolean[] }).__densityFrames; + return frames.filter((frame, index) => index > 0 && frame !== frames[index - 1]).length; + }); + expect(changes).toBeLessThanOrEqual(1); + }), + }); + } finally { + await client.removePlugin(ONE_TOGGLE_PLUGIN_ID); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await rm(directory, { recursive: true, force: true }); + await workspace.cleanup(); + } +} diff --git a/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx index 8a4905b235..b787462ca8 100644 --- a/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx @@ -13,6 +13,8 @@ beforeEach(() => vi.stubGlobal("React", React)); afterEach(cleanup); const base = { id: "x", title: "Context", icon: Scan }; +// The sheet lays its rows out in a column, which is what stretches a row across it. +const SHEET_COLUMN = { display: "flex", flexDirection: "column", width: 382 } as const; type RecordingToast = ToastApi & { error: ReturnType }; @@ -92,6 +94,29 @@ describe("ComposerControls", () => { expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On"); }); + it("gives a sheet row's value the row's width, so a short value is not truncated", () => { + const toast = createToast(); + render( +
+ undefined, + }, + ]} + surface="sheet" + toast={toast} + /> +
, + ); + const value = screen.getByText("Full access"); + expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth); + }); + it("reports a failed command the way a built-in control does, and frees the control", async () => { let reject: (error: Error) => void = () => undefined; const onPress = vi.fn( diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index c93640206a..3963c88193 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -73,6 +73,7 @@ export const AgentControlTrigger = forwardRef( accessibilityLabel={accessibilityLabel} testID={testID} chevron={showCaret ? undefined : null} + block={isSheet} > {isSheet ? ( diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a3bb484b94..29161c6d3c 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -87,6 +87,10 @@ import { composerModelFromHalves } from "@/composer/halves-model"; import { ComposerModelProvider } from "@/composer/model"; import { ComposerAgentProvider, useComposerInput } from "@/composer/session"; import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items"; +import { + PluginToolbarSlot, + usePluginToolbarWidth, +} from "@/composer/agent-controls/plugin-toolbar-slot"; import { useAgentProfileEditor, useAgentProfilePicker, @@ -466,6 +470,7 @@ function ControlledAgentControls({ onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, ); const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId); + const { width: pluginItemsWidth, onLayout: handlePluginItemsLayout } = usePluginToolbarWidth(); const formattedThinkingOptions = useMemo( () => toThinkingControlOptions(thinkingOptions), @@ -488,6 +493,7 @@ function ControlledAgentControls({ () => (optionControls?.controls ?? []).map((control) => { if (control.type === "toggle") return { type: "toggle" as const }; + if (control.desktopTrigger === "icon") return { type: "select" as const, label: null }; const selected = readSelectOption(control, optionControls?.options ?? {}); const choice = control.choices.find((candidate) => candidate.value === selected); return { @@ -503,10 +509,10 @@ function ControlledAgentControls({ hasThinking: canSelectThinking, hasMode: modeControl !== null && modeControl !== undefined, features: featureControls, - pluginItems: pluginItemCount, + pluginItemsWidth, fontScale, }), - [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount], + [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemsWidth], ); const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( @@ -635,7 +641,7 @@ function ControlledAgentControls({ return ( - + {!presentation.foldControls ? ( 0} glyphs={layoutContextValue.glyphs} activeSheet={activeSheet} handleOpenSheet={handleOpenSheet} @@ -698,6 +704,14 @@ function ControlledAgentControls({ canSwitchProvider={Boolean(onSelectProviderAndModel)} /> )} + {!isCompact ? ( + + ) : null} ); @@ -732,7 +746,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps { handleDesktopModelSelect: (providerId: string, modelId: string) => void; handleToolbarOpenChange: (id: string | null) => void; presentation: ComposerControlPresentation; - pluginItemCount: number; + hasPluginItems: boolean; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -766,7 +780,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { handleSheetOpenChange, modeControl, presentation, - pluginItemCount, + hasPluginItems, glyphs, activeSheet, handleOpenSheet, @@ -782,7 +796,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { [t], ); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); - const hasFeatures = featureItems.length > 0 || pluginItemCount > 0; + const hasFeatures = featureItems.length > 0 || hasPluginItems; return ( <> {canSelectModel ? ( @@ -864,17 +878,14 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { ) : ( - <> - - - + )} ); diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 15b6a4f3ac..eed0a189e1 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -47,7 +47,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" as const }], - pluginItems: 0, + pluginItemsWidth: 0, fontScale: 1, }; @@ -116,7 +116,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], - pluginItems: 0, + pluginItemsWidth: 0, fontScale: 1, }, }), @@ -129,7 +129,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }, { type: "select", label: "Tools" }], - pluginItems: 0, + pluginItemsWidth: 0, fontScale: 1, }, }), @@ -142,7 +142,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], - pluginItems: 0, + pluginItemsWidth: 0, fontScale: 1.25, }, }), @@ -158,7 +158,7 @@ describe("composer control layout", () => { hasModel: true, hasThinking: true, hasMode: true, - pluginItems: 0, + pluginItemsWidth: 0, fontScale: 1, }, }; @@ -180,34 +180,61 @@ describe("composer control layout", () => { ).toBe("condensed"); }); - it("condenses to make room for plugin items and keeps them in the overflow", () => { + describe("plugin items", () => { const base = { availableWidth: 430, currentDensity: "full" as const, geometry: POINTER, - controls: { - hasModel: true, - hasThinking: true, - hasMode: true, - features: [], - fontScale: 1, - pluginItems: 0, - }, + }; + const controls = { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [], + fontScale: 1, + pluginItemsWidth: 0, }; - expect( - resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }), - ).toBe("full"); - expect( - resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }), - ).toBe("condensed"); - expect( - resolveComposerControlDensity({ - ...base, - availableWidth: 250, - controls: { ...base.controls, pluginItems: 2 }, - }), - ).toBe("tight"); + it("budgets the width the items measured, so an icon toggle fits where a label would not", () => { + expect( + resolveComposerControlDensity({ + ...base, + controls: { ...controls, pluginItemsWidth: 28 }, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { ...controls, pluginItemsWidth: 125 }, + }), + ).toBe("condensed"); + }); + + it("reserves no overflow button for items that render nothing", () => { + // 264 for the condensed row; an overflow button would take it to 296. + const tight = { ...base, availableWidth: 290, currentDensity: "tight" as const }; + expect(resolveComposerControlDensity({ ...tight, controls })).toBe("condensed"); + expect( + resolveComposerControlDensity({ + ...tight, + controls: { ...controls, pluginItemsWidth: 28 }, + }), + ).toBe("tight"); + }); + + it("holds either density at the full floor while the items' width stays the same", () => { + // 412 is the full floor with a 28-wide item, and 12 the hysteresis. + const atFloor = { ...base, availableWidth: 412 - 12 }; + const withItem = { ...controls, pluginItemsWidth: 28 }; + expect(resolveComposerControlDensity({ ...atFloor, controls: withItem })).toBe("full"); + expect( + resolveComposerControlDensity({ + ...atFloor, + currentDensity: "condensed", + controls: withItem, + }), + ).toBe("condensed"); + }); }); it("gives every toolbar control one shell and one glyph per role and control sizing", () => { @@ -230,6 +257,28 @@ describe("composer control layout", () => { }); }); + it("budgets an icon-only select as the icon it draws", () => { + // Codex's service tier: a select whose toolbar trigger is an icon. 476 with its icon, 543 with + // the label "Priority", against a 502-wide toolbar. + const controls = { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "select" as const, label: null }, { type: "toggle" as const }], + fontScale: 1, + pluginItemsWidth: 28, + }; + + expect( + resolveComposerControlDensity({ + availableWidth: 502, + currentDensity: "condensed", + geometry: POINTER, + controls, + }), + ).toBe("full"); + }); + it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => { const input = { availableWidth: 400, @@ -240,7 +289,7 @@ describe("composer control layout", () => { hasMode: true, features: [{ type: "toggle" as const }], fontScale: 1, - pluginItems: 0, + pluginItemsWidth: 0, }, }; @@ -257,7 +306,7 @@ describe("composer control layout", () => { hasMode: true, features: [{ type: "toggle" as const }, { type: "toggle" as const }], fontScale: 1, - pluginItems: 0, + pluginItemsWidth: 0, }; it("keeps the model family label with icon-only controls on an empty draft", () => { @@ -316,7 +365,7 @@ describe("composer control layout", () => { hasMode: true, features: [], fontScale: 1, - pluginItems: 0, + pluginItemsWidth: 0, }, }; diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index 0be3361f8b..db507efa54 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -26,12 +26,15 @@ export interface ComposerControlPresence { hasThinking: boolean; hasMode: boolean; features: readonly ComposerFeatureControlPresence[]; - /** Plugin composer items. Each is budgeted like a select with a short label. */ - pluginItems: number; + /** The plugin items' measured width in the toolbar row; 0 when none of them renders anything. */ + pluginItemsWidth: number; fontScale: number; } -export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string }; +export type ComposerFeatureControlPresence = + | { type: "toggle" } + /** `label` is what the toolbar trigger draws; null for an icon-only trigger. */ + | { type: "select"; label: string | null }; export interface ComposerControlPresentation { showCarets: boolean; @@ -74,7 +77,6 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG } const DENSITY_HYSTERESIS = 12; -const PLUGIN_ITEM_LABEL = "Plugin item"; /** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */ const MODEL_FAMILY_LABEL_WIDTH = 56; @@ -103,7 +105,7 @@ function resolveFeatureControlWidth( fontScale: number, geometry: ComposerToolbarGeometry, ): number { - if (feature.type === "toggle") return geometry.controlSize; + if (feature.type === "toggle" || feature.label === null) return geometry.controlSize; return ( geometry.controlSize + geometry.iconLabelGap + @@ -122,7 +124,7 @@ function resolveCondensedFloor( if (controls.hasModel) widths.push(chrome + 60 * fontScale); if (controls.hasThinking) widths.push(geometry.controlSize); if (controls.hasMode) widths.push(chrome + 96 * fontScale); - if (controls.features.length > 0 || controls.pluginItems > 0) { + if (controls.features.length > 0 || controls.pluginItemsWidth > 0) { widths.push(geometry.controlSize); } return sumControlWidths(widths, geometry); @@ -142,11 +144,7 @@ function resolveFullFloor( for (const feature of controls.features) { widths.push(resolveFeatureControlWidth(feature, fontScale, geometry)); } - for (let index = 0; index < controls.pluginItems; index++) { - widths.push( - resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry), - ); - } + if (controls.pluginItemsWidth > 0) widths.push(controls.pluginItemsWidth); return sumControlWidths(widths, geometry); } @@ -162,7 +160,7 @@ function resolveTightFloor( } if (controls.hasThinking) widths.push(geometry.controlSize); if (controls.hasMode) widths.push(geometry.controlSize); - if (controls.features.length > 0 || controls.pluginItems > 0) { + if (controls.features.length > 0 || controls.pluginItemsWidth > 0) { widths.push(geometry.controlSize); } return sumControlWidths(widths, geometry); diff --git a/packages/app/src/composer/agent-controls/plugin-toolbar-slot.tsx b/packages/app/src/composer/agent-controls/plugin-toolbar-slot.tsx new file mode 100644 index 0000000000..84f186cbe5 --- /dev/null +++ b/packages/app/src/composer/agent-controls/plugin-toolbar-slot.tsx @@ -0,0 +1,79 @@ +import { useCallback, useState } from "react"; +import { type LayoutChangeEvent, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { TOOLBAR_CONTROL_GAP } from "@/components/ui/control-geometry"; +import { isWeb } from "@/constants/platform"; +import { PluginComposerItems } from "@/plugins/composer-items"; + +/** A retained tab under `display: none` reports 0 by 0; see `styles.slot` for why 0 wide is real. */ +function readSlotWidth(event: LayoutChangeEvent): number | null { + const { width, height } = event.nativeEvent.layout; + return height > 0 ? width : null; +} + +/** What the plugin items take up in the toolbar row, in points, as `PluginToolbarSlot` reports it. */ +export function usePluginToolbarWidth(): { + width: number; + onLayout: (event: LayoutChangeEvent) => void; +} { + const [width, setWidth] = useState(0); + const onLayout = useCallback((event: LayoutChangeEvent) => { + const next = readSlotWidth(event); + if (next !== null) setWidth(next); + }, []); + return { width, onLayout }; +} + +/** + * The plugin items in their toolbar form, measured whether or not the toolbar shows them. An item + * 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. + */ +export function PluginToolbarSlot({ + serverId, + width, + fits, + onLayout, +}: { + serverId: string | null; + width: number; + /** Whether the toolbar has room for the items at the width they measured. */ + fits: boolean; + onLayout: (event: LayoutChangeEvent) => void; +}) { + // An empty slot stays out of the row, or the row's gap would reserve room for nothing. + const inline = fits && width > 0; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + slot: { + flexDirection: "row", + alignItems: "center", + flexShrink: 0, + gap: TOOLBAR_CONTROL_GAP, + // Keeps a slot whose items render nothing distinguishable from one that is not rendered. + minHeight: 1, + }, + measuring: { + position: "absolute", + left: 0, + top: 0, + opacity: 0, + pointerEvents: "none", + ...(isWeb ? ({ visibility: "hidden" } as const) : null), + }, +}); diff --git a/packages/app/src/plugins/composer-items/index.tsx b/packages/app/src/plugins/composer-items/index.tsx index c329a1de6a..6689018456 100644 --- a/packages/app/src/plugins/composer-items/index.tsx +++ b/packages/app/src/plugins/composer-items/index.tsx @@ -26,7 +26,7 @@ function useServerPlugins(serverId: string | null): InstalledPlugin[] { ); } -/** How many composer items the host's plugins registered; the toolbar budgets room for them. */ +/** How many composer items the host's plugins registered, whether or not each renders anything. */ export function usePluginComposerItemCount(serverId: string | null): number { const plugins = useServerPlugins(serverId); return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index b79064ed15..6e4513f6b9 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1984,9 +1984,10 @@ controls render disabled, `setPrompt` and `insertText` throw, and the agent comm "The composer is locked while the agent is being created." `composer.agent.options` and `composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`. -A composer item counts toward how much fits before Paseo knows what it renders. An item that -renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a -control. +Paseo measures what an item renders in the toolbar to decide what fits, so an item that renders +nothing takes no room and adds no overflow button. On wide composers the item stays mounted while +it is in the overflow, and the overflow sheet mounts a second instance while it is open, so keep +effects safe to run twice. On a phone an item mounts only while the overflow sheet is open. The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls) registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an