Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 6d6d78aa82 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.
2026-11-09 16:55:16 +01:00
Mohamed Boudra 33c8164015 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.
2026-11-09 16:41:31 +01:00
Mohamed Boudra 635d80c149 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.
2026-11-09 16:10:17 +01:00
Mohamed Boudra 6de49ad32f 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.
2026-11-09 16:01:59 +01:00
22 changed files with 662 additions and 354 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,
}) => {
@@ -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);
},
};
}
+10 -21
View File
@@ -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);
-1
View File
@@ -90,7 +90,6 @@ export interface Agent {
pendingPermissions: AgentPermissionRequest[];
persistence: AgentPersistenceHandle | null;
runtimeInfo?: AgentRuntimeInfo;
usageScopeKey?: string;
lastUsage?: AgentUsage;
lastError?: string | null;
title: string | null;
+11 -21
View File
@@ -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,
-2
View File
@@ -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;
+4 -3
View File
@@ -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