mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Toggle
|
||||
title="${ONE_TOGGLE_NAME}"
|
||||
icon="Lock"
|
||||
value={value}
|
||||
onValueChange={({ value: next }) => 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<void> {
|
||||
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<void> {
|
||||
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<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>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn> };
|
||||
|
||||
@@ -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(
|
||||
<div style={SHEET_COLUMN}>
|
||||
<Controls
|
||||
items={[
|
||||
{
|
||||
...base,
|
||||
type: "select",
|
||||
value: "full",
|
||||
choices: [{ value: "full", label: "Full access" }],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
]}
|
||||
surface="sheet"
|
||||
toast={toast}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -73,6 +73,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
chevron={showCaret ? undefined : null}
|
||||
block={isSheet}
|
||||
>
|
||||
{isSheet ? (
|
||||
<View style={styles.sheetGlyph}>
|
||||
|
||||
@@ -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 (
|
||||
<ComposerControlLayoutProvider value={layoutContextValue}>
|
||||
<View style={styles.container} onLayout={handleLayout}>
|
||||
<View style={styles.container} onLayout={handleLayout} testID="agent-controls-toolbar">
|
||||
{!presentation.foldControls ? (
|
||||
<DesktopAgentControlsContent
|
||||
provider={provider}
|
||||
@@ -661,7 +667,7 @@ function ControlledAgentControls({
|
||||
handleSheetOpenChange={setOpenSelector}
|
||||
modeControl={modeControl}
|
||||
presentation={presentation}
|
||||
pluginItemCount={pluginItemCount}
|
||||
hasPluginItems={pluginItemsWidth > 0}
|
||||
glyphs={layoutContextValue.glyphs}
|
||||
activeSheet={activeSheet}
|
||||
handleOpenSheet={handleOpenSheet}
|
||||
@@ -698,6 +704,14 @@ function ControlledAgentControls({
|
||||
canSwitchProvider={Boolean(onSelectProviderAndModel)}
|
||||
/>
|
||||
)}
|
||||
{!isCompact ? (
|
||||
<PluginToolbarSlot
|
||||
serverId={modelSelectorServerId}
|
||||
width={pluginItemsWidth}
|
||||
fits={!presentation.aggregateFeatures}
|
||||
onLayout={handlePluginItemsLayout}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ComposerControlLayoutProvider>
|
||||
);
|
||||
@@ -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<ActiveSheet, null>) => 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) {
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComposerControls
|
||||
items={featureItems}
|
||||
surface="toolbar"
|
||||
disabled={disabled}
|
||||
openId={openSelector}
|
||||
onOpenIdChange={handleToolbarOpenChange}
|
||||
onActionComplete={onDropdownClose}
|
||||
/>
|
||||
<PluginComposerItems serverId={modelSelectorServerId} surface="toolbar" />
|
||||
</>
|
||||
<ComposerControls
|
||||
items={featureItems}
|
||||
surface="toolbar"
|
||||
disabled={disabled}
|
||||
openId={openSelector}
|
||||
onOpenIdChange={handleToolbarOpenChange}
|
||||
onActionComplete={onDropdownClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[styles.slot, !inline && styles.measuring]}
|
||||
onLayout={onLayout}
|
||||
testID="plugin-toolbar-items"
|
||||
accessibilityElementsHidden={!inline}
|
||||
importantForAccessibility={inline ? "auto" : "no-hide-descendants"}
|
||||
aria-hidden={!inline}
|
||||
>
|
||||
<PluginComposerItems serverId={serverId} surface="toolbar" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user