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.
This commit is contained in:
Mohamed Boudra
2026-11-10 09:08:11 +07:00
committed by GitHub
parent 9442b5a1b4
commit 82687ec95b
12 changed files with 640 additions and 101 deletions
@@ -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,
}) => {
@@ -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);
});
});
@@ -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 (
<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 () => {};
}`;
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();
}
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 });
}
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 function withToggleAndWatcherComposerItems(
page: Page,
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({
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"), pluginSource);
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);
}),
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);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
@@ -13,6 +13,12 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
const QUIET_WINDOW_FLOOR_MS = 300;
export interface TimelineRequestCounts {
agentId?: string;
@@ -77,48 +83,88 @@ export async function waitForResumeAnswer(
]);
}
interface UpdatingToastTiming {
askedAt: number | null;
shownAt: number | null;
}
declare global {
interface Window {
__updatingToastTiming?: UpdatingToastTiming;
}
}
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
function recordUpdatingToastTiming(): void {
const noteToast = (timing: UpdatingToastTiming) => {
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
timing.shownAt ??= performance.now();
};
const existing = window.__updatingToastTiming;
if (existing) {
existing.askedAt = null;
existing.shownAt = null;
noteToast(existing);
return;
}
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
window.__updatingToastTiming = timing;
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function sendRecordingQuestion(data) {
if (
typeof data === "string" &&
data.includes("agent.timeline.set_subscription.request") &&
data.includes('"agentIds":["')
) {
timing.askedAt ??= performance.now();
}
return send.call(this, data);
};
new MutationObserver(() => noteToast(timing)).observe(document, {
childList: true,
subtree: true,
});
noteToast(timing);
}
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
await waitForNextPaint(page);
const showedTooSoon = await page.evaluate((floorMs) => {
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
if (shownAt === null) return false;
return askedAt === null || shownAt - askedAt < floorMs;
}, QUIET_WINDOW_FLOOR_MS);
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
await page.evaluate(recordUpdatingToastTiming);
return () => expectToastWaitedForTheDaemon(page);
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.addInitScript(recordUpdatingToastTiming);
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
return () => expectToastWaitedForTheDaemon(page);
}
/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */
export async function returnWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await hideTabAndReturn(page);
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
async function waitForNextPaint(page: Page): Promise<void> {
@@ -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}>
@@ -85,6 +85,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,
@@ -464,6 +468,7 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId);
const { width: pluginItemsWidth, onLayout: handlePluginItemsLayout } = usePluginToolbarWidth();
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
@@ -486,6 +491,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 {
@@ -501,10 +507,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(
@@ -633,7 +639,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}
@@ -659,7 +665,7 @@ function ControlledAgentControls({
handleSheetOpenChange={setOpenSelector}
modeControl={modeControl}
presentation={presentation}
pluginItemCount={pluginItemCount}
hasPluginItems={pluginItemsWidth > 0}
glyphs={layoutContextValue.glyphs}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
@@ -696,6 +702,14 @@ function ControlledAgentControls({
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
)}
{!isCompact ? (
<PluginToolbarSlot
serverId={modelSelectorServerId}
width={pluginItemsWidth}
fits={!presentation.aggregateFeatures}
onLayout={handlePluginItemsLayout}
/>
) : null}
</View>
</ComposerControlLayoutProvider>
);
@@ -730,7 +744,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;
@@ -764,7 +778,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
handleSheetOpenChange,
modeControl,
presentation,
pluginItemCount,
hasPluginItems,
glyphs,
activeSheet,
handleOpenSheet,
@@ -780,7 +794,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 ? (
@@ -862,17 +876,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", () => {
@@ -231,6 +258,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,
@@ -241,7 +290,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -258,7 +307,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", () => {
@@ -317,7 +366,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -27,12 +27,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;
@@ -69,7 +72,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;
@@ -98,7 +100,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 +
@@ -117,7 +119,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);
@@ -137,11 +139,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);
}
@@ -157,7 +155,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,82 @@
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";
/** 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, and inactive so an open picker closes with it.
*/
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}
>
<RetainedPanelActivity active={inline}>
<PluginComposerItems serverId={serverId} surface="toolbar" />
</RetainedPanelActivity>
</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);