mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d6d78aa82 | ||
|
|
33c8164015 | ||
|
|
635d80c149 | ||
|
|
6de49ad32f |
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
recordContextWindowFrames,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
@@ -300,10 +299,9 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const slowSource = gate();
|
||||
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
|
||||
const closedEarly = await hoverContextWindowMeter(page);
|
||||
await expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
await usage.resumeAgent(agent);
|
||||
const resumed = gate();
|
||||
const finished = gate();
|
||||
usage.answerNext({
|
||||
@@ -387,58 +385,6 @@ for (const theme of ["light", "dark"] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test("an open with loaded usage paints it in its first frame and never moves", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
usage.answerNext([onWorkLogin(claude!)]);
|
||||
const loaded = await hoverContextWindowMeter(page);
|
||||
await expect(usageCard(loaded, "claude:work")).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
// The reopen's own request stays unanswered until the card has been on screen a while.
|
||||
const refetch = gate();
|
||||
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const reopened = await hoverContextWindowMeter(page);
|
||||
await page.waitForTimeout(300);
|
||||
refetch.open();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(usageCard(reopened, "claude:work")).toBeVisible();
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.filter((frame) => frame.loading)).toEqual([]);
|
||||
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
|
||||
const arrival = gate();
|
||||
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
arrival.open();
|
||||
await expect(usageCard(card, "claude:work")).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.some((frame) => frame.loading)).toBe(true);
|
||||
expect(frames.at(-1)?.usageCards).toBe(1);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
||||
page,
|
||||
agent,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,83 +49,6 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
/** The hover card as one painted frame showed it, or unmounted. */
|
||||
export interface ContextWindowFrame {
|
||||
mounted: boolean;
|
||||
bottom: number | null;
|
||||
loading: boolean;
|
||||
usageCards: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
|
||||
* frame is seen. Stop it only after everything has settled.
|
||||
*/
|
||||
export async function recordContextWindowFrames(
|
||||
page: Page,
|
||||
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
|
||||
await page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowFrames = [];
|
||||
recorder.__contextWindowRecording = true;
|
||||
const findCard = () =>
|
||||
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
|
||||
const read = (card: HTMLElement | null): ContextWindowFrame => ({
|
||||
mounted: card !== null,
|
||||
bottom: card?.getBoundingClientRect().bottom ?? null,
|
||||
loading: card?.innerText.includes("Loading usage...") ?? false,
|
||||
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
|
||||
});
|
||||
// A card that just resized has not been painted yet: its frame is read from an observer,
|
||||
// which runs before that paint.
|
||||
let watched: HTMLElement | null = null;
|
||||
let paintedHeight: number | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
const watch = (card: HTMLElement) => {
|
||||
observer = new ResizeObserver(() => {
|
||||
paintedHeight = card.offsetHeight;
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
});
|
||||
observer.observe(card);
|
||||
};
|
||||
const channel = new MessageChannel();
|
||||
// A message posted from an animation frame runs after that frame's paint.
|
||||
channel.port1.addEventListener("message", () => {
|
||||
const card = findCard();
|
||||
if (card !== watched) {
|
||||
observer?.disconnect();
|
||||
watched = card;
|
||||
paintedHeight = null;
|
||||
if (card) watch(card);
|
||||
}
|
||||
if (!card || card.offsetHeight === paintedHeight) {
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
}
|
||||
});
|
||||
channel.port1.start();
|
||||
const sample = () => {
|
||||
if (!recorder.__contextWindowRecording) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
return {
|
||||
stop: () =>
|
||||
page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowRecording = false;
|
||||
return recorder.__contextWindowFrames;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
@@ -216,8 +139,6 @@ export interface AgentUsageScript {
|
||||
agentRequests(): string[];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
|
||||
resumeAgent(agent: MockAgentSession): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,8 +169,6 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
usage
|
||||
.listRequests()
|
||||
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||
resumeAgent: (agent) =>
|
||||
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface UsageListRequest {
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
|
||||
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
@@ -99,26 +97,6 @@ function withUsageSupportFeature(
|
||||
});
|
||||
}
|
||||
|
||||
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
|
||||
};
|
||||
} | null;
|
||||
const agent = envelope?.message?.payload?.agent;
|
||||
if (
|
||||
envelope?.message?.type !== "agent_update" ||
|
||||
envelope.message.payload?.kind !== "upsert" ||
|
||||
typeof agent?.id !== "string" ||
|
||||
typeof agent.usageScopeKey !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { agentId: agent.id, key: agent.usageScopeKey };
|
||||
}
|
||||
|
||||
function pick<T>(values: T[], index: number): T {
|
||||
const value = values[Math.min(index, values.length - 1)];
|
||||
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
|
||||
@@ -149,16 +127,6 @@ export async function installUsageReportsFixture(
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageScopes = new Map<string, string>();
|
||||
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
|
||||
const usageScopeCounts = new Map<string, number>();
|
||||
const usageScopeCounter = (agentId: string) => {
|
||||
const existing = usageScopeChanges.get(agentId);
|
||||
if (existing) return existing;
|
||||
const created = createCounter();
|
||||
usageScopeChanges.set(agentId, created);
|
||||
return created;
|
||||
};
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
@@ -271,15 +239,6 @@ export async function installUsageReportsFixture(
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
: null;
|
||||
const scope = agentUsageScope(message);
|
||||
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
|
||||
usageScopes.set(scope.agentId, scope.key);
|
||||
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
|
||||
// The app must hold the new scope before a test acts on it.
|
||||
ws.send(serverInfo ?? message);
|
||||
usageScopeCounter(scope.agentId).increment();
|
||||
return;
|
||||
}
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
@@ -287,10 +246,5 @@ export async function installUsageReportsFixture(
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
untilUsageScopeChanges: async (agentId, action) => {
|
||||
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
|
||||
await action();
|
||||
await usageScopeCounter(agentId).waitFor(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,17 +322,6 @@ export function HoverCardContent(props: HoverCardContentProps): ReactElement | n
|
||||
);
|
||||
}
|
||||
|
||||
// A card above its trigger holds its bottom edge, so content that loads while it is open grows it
|
||||
// upward without a correction. Anchored by its top, it would paint over the trigger until placed.
|
||||
type CardPosition = { left: number; top: number } | { left: number; bottom: number };
|
||||
|
||||
function isSamePosition(current: CardPosition | null, next: CardPosition): boolean {
|
||||
if (!current || current.left !== next.left) return false;
|
||||
return "top" in current
|
||||
? "top" in next && current.top === next.top
|
||||
: "bottom" in next && current.bottom === next.bottom;
|
||||
}
|
||||
|
||||
function readDomRect(view: View | null): Rect | null {
|
||||
const element = view as unknown as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
@@ -360,7 +349,7 @@ function HoverCardSurface({
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const overlayRef = useRef<View>(null);
|
||||
const [position, setPosition] = useState<CardPosition | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const triggerRect = readDomRect(triggerRef.current);
|
||||
@@ -368,7 +357,7 @@ function HoverCardSurface({
|
||||
const content = contentRef.current as unknown as HTMLElement | null;
|
||||
if (!triggerRect || !overlayRect || !content) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y, actualPlacement } = computePosition({
|
||||
const { x, y } = computePosition({
|
||||
triggerRect,
|
||||
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
@@ -377,19 +366,19 @@ function HoverCardSurface({
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
const left = x - overlayRect.x;
|
||||
const next: CardPosition =
|
||||
actualPlacement === "top"
|
||||
? { left, bottom: overlayRect.height - (y + content.offsetHeight - overlayRect.y) }
|
||||
: { left, top: y - overlayRect.y };
|
||||
setPosition((current) => (isSamePosition(current, next) ? current : next));
|
||||
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
|
||||
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
|
||||
}, [alignment, contentRef, offset, placement, triggerRef]);
|
||||
|
||||
useLayoutEffect(place, [place]);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({ position: "absolute" as const, ...(position ?? { top: -9999, left: -9999 }) }),
|
||||
[position],
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
}),
|
||||
[position?.x, position?.y],
|
||||
);
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -90,7 +90,6 @@ export interface Agent {
|
||||
pendingPermissions: AgentPermissionRequest[];
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
usageScopeKey?: string;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string | null;
|
||||
title: string | null;
|
||||
|
||||
@@ -47,16 +47,8 @@ function usageReportsQueryKey(serverId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "reports"] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(serverId: string, agentId: string, usageScopeKey: string | undefined) {
|
||||
return [...hostUsageQueryKey(serverId), "agent", agentId, usageScopeKey ?? null] as const;
|
||||
}
|
||||
|
||||
function useAgentUsageScopeKey(serverId: string, agentId: string | undefined) {
|
||||
return useSessionStore((state) =>
|
||||
agentId === undefined
|
||||
? undefined
|
||||
: state.sessions[serverId]?.agents.get(agentId)?.usageScopeKey,
|
||||
);
|
||||
function agentUsageQueryKey(serverId: string, agentId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "agent", agentId] as const;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
@@ -198,25 +190,24 @@ export function useUsageHostReports({
|
||||
|
||||
/**
|
||||
* The reports of the account an agent runs under, as its meter's tooltip or sheet shows them.
|
||||
* Fetched while either is mounted, so only while it is open. Kept in the cache between opens, so
|
||||
* the first frame of a reopen shows them while its own request refreshes them.
|
||||
* Fetched while either is mounted, so only while it is open.
|
||||
*/
|
||||
export function useAgentUsage(serverId: string, agentId: string): AgentUsageView {
|
||||
const queryClient = useQueryClient();
|
||||
const canReport = useHostReportsUsage(serverId);
|
||||
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
|
||||
const queryKey = agentUsageQueryKey(serverId, agentId, usageScopeKey);
|
||||
const queryKey = agentUsageQueryKey(serverId, agentId);
|
||||
const query = useFetchQuery({
|
||||
queryKey,
|
||||
// Consuming the signal cancels the request when the details close, so a report still on its
|
||||
// way cannot land under a scope the agent has since left.
|
||||
// Consuming the signal cancels the request when the details close, so a reopen sends a new
|
||||
// request instead of joining one scoped to the login the agent ran under before.
|
||||
queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }),
|
||||
enabled: canReport,
|
||||
// Another agent's reports never stand in while this one's load.
|
||||
dataShape: "value",
|
||||
// COMPAT(agentUsageScopeKey): added in v0.15.4, remove after 2027-05-09. Without the key,
|
||||
// an agent resumed under another login keeps its query, so its reports die with the details.
|
||||
gcTime: usageScopeKey === undefined ? 0 : Infinity,
|
||||
// Dropped as soon as the details close: an agent resumed under another login keeps its ID, so
|
||||
// reports kept from an earlier open could show the old login. The daemon's cache keeps each
|
||||
// open's request cheap.
|
||||
gcTime: 0,
|
||||
// The daemon's errors (an unknown agent) do not heal on retry, and reopening the details
|
||||
// fetches again; retrying would hold the loading sentence for seconds instead.
|
||||
retry: false,
|
||||
@@ -264,14 +255,13 @@ export function useReportRefresh(
|
||||
agentId?: string,
|
||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||
const queryClient = useQueryClient();
|
||||
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true, agentId),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueryData<UsageReportEntry[]>(
|
||||
agentId === undefined
|
||||
? usageReportsQueryKey(serverId)
|
||||
: agentUsageQueryKey(serverId, agentId, usageScopeKey),
|
||||
: agentUsageQueryKey(serverId, agentId),
|
||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
pendingPermissions: agent.pendingPermissions,
|
||||
persistence: agent.persistence,
|
||||
...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}),
|
||||
...(agent.usageScopeKey ? { usageScopeKey: agent.usageScopeKey } : {}),
|
||||
...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}),
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
@@ -127,7 +126,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
pendingPermissions: snapshot.pendingPermissions ?? [],
|
||||
persistence: snapshot.persistence ?? null,
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
usageScopeKey: snapshot.usageScopeKey,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
|
||||
@@ -923,8 +923,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
|
||||
persistence: AgentPersistenceHandleSchema.nullable(),
|
||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||
/** Opaque. Changes whenever the accounts this agent reports usage for can change. */
|
||||
usageScopeKey: z.string().optional(),
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
|
||||
@@ -271,19 +271,6 @@ describe("toAgentPayload", () => {
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("changes the usage scope key when the harness session or its model changes", () => {
|
||||
function usageScopeKey(usageSession: ReturnType<NonNullable<AgentSession["usageSession"]>>) {
|
||||
const session = { usageSession: () => usageSession } as unknown as AgentSession;
|
||||
return toAgentPayload(createManagedAgent({ session })).usageScopeKey;
|
||||
}
|
||||
const scope = { provider: "claude", model: "opus", env: {}, sessionKey: "session-a" };
|
||||
|
||||
expect(usageScopeKey(scope)).toBe(usageScopeKey({ ...scope }));
|
||||
expect(usageScopeKey({ ...scope, sessionKey: "session-b" })).not.toBe(usageScopeKey(scope));
|
||||
expect(usageScopeKey({ ...scope, model: "sonnet" })).not.toBe(usageScopeKey(scope));
|
||||
expect(usageScopeKey(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits usage when any numeric usage field is NaN", () => {
|
||||
const fields = [
|
||||
"inputTokens",
|
||||
|
||||
@@ -114,12 +114,6 @@ export function toStoredAgentRecord(
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
|
||||
/** Account discovery depends on the harness session and its model, so the key follows both. */
|
||||
function projectUsageScopeKey(agent: ManagedAgent): string | undefined {
|
||||
const usageSession = agent.session?.usageSession?.();
|
||||
return usageSession ? `${usageSession.sessionKey}:${usageSession.model ?? ""}` : undefined;
|
||||
}
|
||||
|
||||
export function toAgentPayload(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions,
|
||||
@@ -168,11 +162,6 @@ export function toAgentPayload(
|
||||
...visibilityFlags(agent),
|
||||
};
|
||||
|
||||
const usageScopeKey = projectUsageScopeKey(agent);
|
||||
if (usageScopeKey !== undefined) {
|
||||
payload.usageScopeKey = usageScopeKey;
|
||||
}
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
if (usage !== undefined) {
|
||||
payload.lastUsage = usage;
|
||||
|
||||
@@ -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