Files
paseo/packages/app/e2e/support/helpers/plugin-composers-by-reference.ts
T
Mohamed Boudra d8372eb25d fix(app): judge compact composer coverage by where it is hosted
The Explorer dock always reported its pane as focused, so a workspace sheet
over an Explorer composer still counted as on screen and focus() put the
cursor behind the sheet. It now suspends pane focus like the main panes do.

A composer on a screen with no panes counted as a main pane, so an Explorer
selection left over from the workspace marked New workspace as covered and
focus() rejected on a visible input. Only the agent list can cover those
screens, because the Explorer lives in the workspace.
2026-11-10 01:53:10 +01:00

614 lines
25 KiB
TypeScript

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 { gotoWorkspace } from "./launcher";
import { openCommandCenter } from "./command-center";
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import {
expectNoSheetOpen,
expectOpenTabCount,
promptMentioning,
sheet,
tapSheetBackdrop,
} from "./sheet";
import { seedSubagent } from "./subagents";
import { getServerId } from "./server-id";
import {
createAgentTabFromMenu,
dragTabIntoExplorer,
ensureExplorerSidebar,
} from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
const FILE_LINK = "linked.ts:3";
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
function prompt(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." });
}
async function createMockAgent(workspace: Workspace, title: string) {
return workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
model: "ten-second-stream",
modeId: "load-test",
});
}
async function runCommand(page: Page, title: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
export async function withComposerToolsExample(
page: Page,
run: (author: {
openComposerToolsInExplorer(): Promise<void>;
typeInComposer(text: string, cursorAfter: number): Promise<void>;
insertChecklistFromPanel(): Promise<void>;
expectComposerText(text: string): Promise<void>;
markForReviewFromCommandCenter(): Promise<void>;
focusANewAgentTab(): Promise<void>;
focusTheAgentTab(): Promise<void>;
expectPanelFocusedAgent(focused: boolean): Promise<void>;
expectPanelShowsReview(needsReview: boolean): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
const agent = await createMockAgent(workspace, "Composer tools agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("composer-controls");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
openComposerToolsInExplorer: () =>
test.step("open the Composer tools panel in the Explorer", async () => {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: "Composer tools", exact: true })
.click();
await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible();
}),
typeInComposer: (text, cursorAfter) =>
test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => {
const input = prompt(page);
await input.fill(text);
await input.press("Home");
for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight");
}),
insertChecklistFromPanel: () =>
test.step("press the panel's button", () =>
page
.getByRole("button", { name: "Insert checklist into the focused agent", exact: true })
.click()),
expectComposerText: (text) =>
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
markForReviewFromCommandCenter: () =>
test.step("run the Command Center item", () =>
runCommand(page, "Composer controls: mark for review")),
focusANewAgentTab: () =>
test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)),
focusTheAgentTab: () =>
test.step("focus the running agent's tab again", () =>
page
.getByTestId(`workspace-tab-agent_${agent.id}`)
.filter({ visible: true })
.first()
.click({ position: { x: 12, y: 13 } })),
expectPanelFocusedAgent: (focused) =>
test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => {
await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount(
focused ? 1 : 0,
);
await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount(
focused ? 0 : 1,
);
}),
expectPanelShowsReview: (needsReview) =>
test.step(`the panel's snapshot says needs review: ${needsReview}`, () =>
expect(
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
).toBeVisible()),
});
} finally {
await client.removePlugin("composer-controls-example");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
/**
* A plugin that names an agent by id from a Command Center item while the user is in another
* workspace: the target's workspace is not on screen, so its composer is not mounted.
*/
export async function withPluginWritingToAnotherWorkspace(
page: Page,
run: (author: {
openTargetAgent(): Promise<void>;
goToAnotherWorkspace(): Promise<void>;
insertFromCommandCenter(): Promise<void>;
returnToTargetWorkspace(): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" });
const target = await createMockAgent(workspace, "Target agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
client.addCommandCenterItem({
id: "note",
title: "Composer ref: add a note",
icon: "Flag",
context: "global",
onSelect({ composers }) {
for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin");
},
});
return () => {};
}`,
);
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
const workspaceRow = (id: string) =>
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openTargetAgent: () =>
test.step("open the agent the plugin will write to", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
goToAnotherWorkspace: () =>
test.step("go to another workspace, so the agent's composer is not on screen", async () => {
await workspaceRow(elsewhere.workspaceId).click();
// The app keeps the workspace it left mounted, out of sight.
await expect(targetTab().first()).toBeHidden();
}),
insertFromCommandCenter: () =>
test.step("run the plugin's Command Center item", () =>
runCommand(page, "Composer ref: add a note")),
returnToTargetWorkspace: () =>
test.step("return to the agent's workspace without reloading the app", async () => {
await workspaceRow(workspace.workspaceId).click();
await expect(targetTab().first()).toBeVisible({ timeout: 30_000 });
}),
expectComposerText: (text) =>
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
});
} finally {
await client.removePlugin("composer-ref");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await elsewhere.cleanup();
await workspace.cleanup();
}
}
/**
* A plugin that reaches one agent's composer from a pill and from Command Center items, and asks
* for focus, wherever the user is.
*/
export async function withPluginFocusingAComposer(
page: Page,
run: (author: {
openOtherAgent(): Promise<void>;
openTargetAgent(): Promise<void>;
goToAnotherWorkspace(): Promise<void>;
moveFocusOutOfComposer(): Promise<void>;
pressPill(): Promise<void>;
focusFromCommandCenter(): Promise<void>;
selectPromptFromCommandCenter(): Promise<void>;
useCompactLayout(): Promise<void>;
coverComposerWithExplorerPanel(): Promise<void>;
pressPanelFocusButton(): Promise<void>;
expectExplorerPanelClosed(): Promise<void>;
fillComposer(text: string): Promise<void>;
expectOnTargetAgent(): Promise<void>;
expectComposerFocused(): Promise<void>;
typeAtCursor(text: string): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus" });
const elsewhere = await seedWorkspace({ repoPrefix: "focus-away-", title: "Focus away" });
const target = await createMockAgent(workspace, "Target agent");
const other = await createMockAgent(workspace, "Other agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-focus-"));
const targetId = JSON.stringify(target.id);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-focus", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { Pressable, Text } from "react-native";
export default function contribute(client) {
client.addComposerPill({
id: "hello",
workspaceId: ${JSON.stringify(workspace.workspaceId)},
agentId: ${targetId},
button: {
title: "Add HELLO to the composer",
icon: "Hand",
label: "Hello",
behavior: {
kind: "action",
async onPress() {
for (const composer of client.composers({ agentId: ${targetId} })) {
composer.insertText("HELLO");
await composer.focus();
}
},
},
},
});
client.addWorkspacePanel({
id: "tools",
title: "Focus tools",
icon: "ListChecks",
locations: ["explorer"],
Component() {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Focus the target agent"
onPress={() => client.composers({ agentId: ${targetId} })[0]?.focus()}
>
<Text>Focus the target agent</Text>
</Pressable>
);
},
});
client.addCommandCenterItem({
id: "focus",
title: "Composer focus: focus the target",
icon: "Flag",
context: "global",
onSelect({ composers }) {
return composers({ agentId: ${targetId} })[0].focus();
},
});
client.addCommandCenterItem({
id: "select",
title: "Composer focus: select the prompt",
icon: "Flag",
context: "global",
onSelect({ composers }) {
const composer = composers({ agentId: ${targetId} })[0];
return composer.focus({ selection: { start: 0, end: composer.prompt.length } });
},
});
return () => {};
}`,
);
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
const workspaceRow = (id: string) =>
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
const panelFocusButton = () =>
page.getByRole("button", { name: "Focus the target agent", exact: true });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openOtherAgent: () =>
test.step("open another agent, so the target's tab is not showing", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
openTargetAgent: () =>
test.step("open the target agent", async () => {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
goToAnotherWorkspace: () =>
test.step("go to another workspace", async () => {
await workspaceRow(elsewhere.workspaceId).click();
await expect(targetTab().first()).toBeHidden();
}),
moveFocusOutOfComposer: () =>
test.step("the composer does not have focus", async () => {
await prompt(page).blur();
await expect(prompt(page)).not.toBeFocused();
}),
pressPill: () =>
test.step("press the pill", () => page.getByRole("button", { name: "Hello" }).click()),
focusFromCommandCenter: () =>
test.step("run the plugin's focus command", () =>
runCommand(page, "Composer focus: focus the target")),
selectPromptFromCommandCenter: () =>
test.step("run the plugin's select command", () =>
runCommand(page, "Composer focus: select the prompt")),
useCompactLayout: () =>
test.step("use a phone-sized window", () => page.setViewportSize(COMPACT)),
coverComposerWithExplorerPanel: () =>
test.step("open the plugin's panel in the Explorer, covering the composer", async () => {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: "Focus tools", exact: true })
.click();
await expect(panelFocusButton()).toBeVisible();
}),
pressPanelFocusButton: () =>
test.step("press the panel's button", () => panelFocusButton().click()),
expectExplorerPanelClosed: () =>
test.step("the Explorer no longer covers the composer", () =>
expect(panelFocusButton()).toBeHidden({ timeout: 30_000 })),
fillComposer: (text) =>
test.step(`write "${text}" in the composer`, () => prompt(page).fill(text)),
expectOnTargetAgent: () =>
test.step("the target agent's tab is showing", async () => {
await expect(targetTab().filter({ visible: true }).first()).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
}),
expectComposerFocused: () =>
test.step("the composer input has focus", () =>
expect(prompt(page)).toBeFocused({ timeout: 30_000 })),
typeAtCursor: (text) => test.step(`type "${text}"`, () => page.keyboard.type(text)),
expectComposerText: (text) =>
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
});
} finally {
await client.removePlugin("composer-focus");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await elsewhere.cleanup();
await workspace.cleanup();
}
}
export { CHECKLIST };
type HeldComposerGlobal = typeof globalThis & {
heldComposer?: { focus(): Promise<void> };
};
export async function withPluginHoldingNewWorkspaceComposer(
page: Page,
run: (author: {
openNewWorkspace(): Promise<void>;
leaveCompactExplorerForNewWorkspace(): Promise<void>;
openSettings(): Promise<void>;
expectHeldComposerFocusResult(result: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.heldComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openNewWorkspace: () =>
test.step("open the New workspace composer", async () => {
await openGlobalNewWorkspaceComposer(page);
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
}),
leaveCompactExplorerForNewWorkspace: () =>
test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await page.setViewportSize(COMPACT);
await expect(await ensureExplorerSidebar(page)).toBeVisible();
await page.evaluate(() => {
(globalThis as HeldComposerGlobal).heldComposer = undefined;
});
await page.keyboard.press("ControlOrMeta+n");
await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
// A compact composer mounts plugin items inside its controls sheet.
await page.getByRole("button", { name: /^Select model/ }).click();
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
await tapSheetBackdrop(page);
await expectNoSheetOpen(page);
}),
openSettings: () =>
test.step("push Settings over the New workspace screen", async () => {
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}),
expectHeldComposerFocusResult: (result) =>
test.step(`the held composer's focus() reports ${result}`, async () => {
const actual = await page.evaluate(() =>
(globalThis as HeldComposerGlobal).heldComposer?.focus().then(
() => "focused",
(error: Error) => error.message,
),
);
expect(actual).toBe(result);
}),
});
} finally {
await client.removePlugin("held-composer");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
type FocusAgentGlobal = typeof globalThis & {
focusAgent?: (agentId: string) => Promise<void>;
};
export async function withPluginFocusingASubagent(
page: Page,
run: (author: {
openParentAgent(): Promise<void>;
openExplorer(): Promise<void>;
focusSubagent(): Promise<void>;
expectSubagentTabReadyToType(): Promise<void>;
keepParentInExplorerOnCompactScreen(): Promise<void>;
focusParent(): Promise<void>;
expectParentReadyToTypeInExplorer(): Promise<void>;
coverExplorerComposerWithFileSheet(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "focus-subagent-",
title: "Focus parent",
initialPrompt: promptMentioning(FILE_LINK),
});
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Focus child" },
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-subagent-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "focus-subagent", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
return () => {};
}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(COMPACT);
await gotoAppShell(page);
const focusAgent = async (agentId: string) => {
const result = await page.evaluate(
(id) =>
(globalThis as FocusAgentGlobal).focusAgent?.(id).then(
() => "focused",
(error: Error) => error.message,
),
agentId,
);
expect(result).toBe("focused");
};
await run({
keepParentInExplorerOnCompactScreen: () =>
test.step("move the parent into the Explorer, then narrow the window", async () => {
await page.setViewportSize(WIDE);
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
// The seeded turn must finish first, so the drag isn't racing its updates to the tab.
await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
const explorer = await ensureExplorerSidebar(page);
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
await expect(
explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
).toBeVisible();
await page.setViewportSize(COMPACT);
}),
focusParent: () =>
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
expectParentReadyToTypeInExplorer: () =>
test.step("the cursor is in the Explorer's composer", async () => {
await expectNoSheetOpen(page);
await expect(prompt(page).filter({ visible: true })).toBeFocused();
}),
coverExplorerComposerWithFileSheet: () =>
test.step("open a file's sheet over the Explorer's composer", async () => {
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
await expect(sheet(page, "linked.ts")).toBeVisible();
}),
openParentAgent: () =>
test.step("open the parent agent on a phone", async () => {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
await expectOpenTabCount(page, 1);
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
}),
openExplorer: () =>
test.step("open the Explorer over the parent's composer", async () => {
await expect(await ensureExplorerSidebar(page)).toBeVisible();
}),
focusSubagent: () =>
test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
expectSubagentTabReadyToType: () =>
test.step("the subagent is a tab with the cursor in its composer", async () => {
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 2);
await expect(prompt(page)).toBeFocused();
}),
});
} finally {
await client.removePlugin("focus-subagent");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}