mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* feat(plugins): let a plugin put the cursor in any agent's composer
A pill could insert text but left the cursor elsewhere, so the user had
to click the composer before typing. Plugins can address any agent's
composer, and an author cannot know where the user is, so focus() owns
revealing the agent as well as focusing it.
A running agent's composer holds one pending request and navigates to
the agent, completing when an input that can take the keyboard attaches.
Drafts and the New workspace composer have nothing to navigate to and
must not pull the user back, so they reject when off screen.
* fix(app): stop treating a covered New workspace composer as on screen
The screen passed isPaneFocused={true}, so with Settings pushed over it
the input still counted as revealed. focus() on a held composer resolved
on native with no visible cursor, and rejected with the wrong error on web.
The screen now reports its real route focus.
* fix(app): reach a compact subagent's composer and keep a focus selection
A compact sheet has no composer, so focus() on a subagent without a tab
opened a read-only sheet and timed out. It now promotes the sheet to a
tab, the same path as Open as tab.
A pending focus ran inside the effect that attached the input, before a
later effect in that commit applied a prompt set alongside it, so its
selection was clamped against the old text. It now runs after the commit.
* fix(app): show a compact subagent's tab when focus promotes its sheet
navigateToAgent stops at the sheet, so nothing closed an Explorer panel
covering the promoted tab and focus() timed out. The promoted tab now
goes through the same reveal as any other navigated tab.
* fix(app): open focus targets as tabs and focus Explorer composers on compact
Promoting a sheet after navigating could not reach an open deferred
until the workspace loaded, so the request now asks for a tab up front
with asTab, which travels with the deferred open. That removes the
post-navigation promotion.
A compact composer counted as covered whenever the panel target was not
the agent panel, so one hosted in the Explorer could never take focus.
Coverage now follows the composer's own host.
* 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.
* fix(app): close the composer's own sheets and menus when a plugin focuses it
focus() judged a composer on screen by its pane and panel alone, so a sheet
or menu the composer opened over itself, like the compact controls sheet
that hosts plugin Actions or a Subagents track page, stayed up while the
cursor went behind it. Navigation cannot close those, so focus() now closes
them itself.
Sheets and menus register with the nearest OverlayDismissalScope while open.
The composer opens one, and the agent panel opens one around its whole chat
because the tracks render outside the composer; a nested scope joins the
enclosing one.
* fix(app): close a composer's open picker when a plugin focuses it
The thinking, provider-option and plugin selectors render Combobox, which
never joined the composer's overlay dismissal scope. focus() left the
picker open, resolved on the textarea's brief focus, and the picker's
focus enforcement then took the cursor back. Combobox now registers its
close while open, like sheets and menus.
* fix(app): close a composer's modals before focus places the cursor
An image preview opened from the composer stayed over it after focus()
succeeded: the shared Modal never joined the overlay dismissal scope.
Modal now registers its onRequestClose while visible. Closing it then
exposed a second race: a web modal hands focus back to the button that
opened it in the commit that removes it, after the cursor was placed.
Dismissal now resolves once every closed overlay has unregistered, and
the composer places the cursor after that, waiting at most 1s so a
plugin modal that refuses to close cannot hold the promise forever.
A focus request replaced by a newer one resolved immediately, even when
the composer never came on screen. It now settles with the newer one.
* fix(app): close every composer sheet on focus and keep the latest request
The context usage sheet opens through the bottom sheet visibility hook
directly, so it stayed over the composer after focus(). Registration
moves to that hook, which every controlled bottom sheet uses;
AdaptiveModalSheet registers only its desktop dialog.
Two focus() calls in one turn overwrote each other's wait for an
overlay to close, so the older one resumed a second later and put its
selection back. Callers now share each overlay's close, and the latest
request places the cursor while earlier ones settle with it.
On native, focus() reported success when the input refused focus, as a
non-editable input does during dictation. It now checks isFocused().
* fix(app): let a focus call made while the input attaches decide the selection
A queued focus was dispatched in a microtask after the input attached, so a
focus() made in between (from a mounting item, for example) was overwritten
by the older selection. Each call now carries its sequence number; a queued
request that is no longer the latest settles with the newer one instead of
placing the cursor.
* fix(app): close a workspace sheet that focus dismisses before it animates open
A bottom sheet modal that unmounts after presenting but before its first
animation is still INITIAL, and the library's portal unmount returned early
for that status. Its portal stayed on the host and kept sliding open with no
owner, so composer.focus() removed the sheet from the workspace but left it
covering the input. The patch unmounts the portal in that case.
The compact Explorer focus test also waits for the phone layout before
focusing, since focus otherwise resolved against the wide layout.
127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
|
|
/** A sheet tab's body, named after the tab it shows. */
|
|
export function sheet(page: Page, label: string): Locator {
|
|
return page.getByRole("dialog", { name: label });
|
|
}
|
|
|
|
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
|
|
export function sheetHeader(page: Page, label: string): Locator {
|
|
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
|
}
|
|
|
|
/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
|
|
const SHEET_SNAP_FRACTION = 0.92;
|
|
/** The header starts below the sheet's drag indicator, inside the handle. */
|
|
const SHEET_INDICATOR_MAX_HEIGHT = 32;
|
|
|
|
/**
|
|
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
|
|
* covers the workspace header; a sheet sunk below it hides content.
|
|
*/
|
|
export async function expectSheetAtSnap(page: Page, label: string): Promise<void> {
|
|
const viewportHeight = page.viewportSize()?.height ?? 0;
|
|
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
|
|
const header = sheetHeader(page, label);
|
|
const body = sheet(page, label);
|
|
await expect
|
|
.poll(async () => {
|
|
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
|
|
if (!headerBox || !bodyBox) return null;
|
|
const top = Math.round(headerBox.y);
|
|
return {
|
|
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
|
|
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
|
|
};
|
|
})
|
|
.toEqual({ dockedToBottom: true, topAtSnap: true });
|
|
}
|
|
|
|
export async function expectOpenTabCount(page: Page, count: number): Promise<void> {
|
|
await expect(
|
|
page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
|
|
).toBeVisible();
|
|
}
|
|
|
|
export async function closeSheet(page: Page, label: string): Promise<void> {
|
|
await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
|
|
await expect(sheet(page, label)).toHaveCount(0);
|
|
}
|
|
|
|
/** Steps a sheet opened from a list back to that list, in the same sheet. */
|
|
export async function backToList(page: Page, label: string): Promise<void> {
|
|
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
|
|
await expect(sheet(page, label)).toHaveCount(0);
|
|
}
|
|
|
|
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
|
|
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
|
await expect(sheet(page, label)).toHaveCount(0);
|
|
}
|
|
|
|
export async function replaceSheetFileText(
|
|
page: Page,
|
|
label: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
|
|
await editor.click();
|
|
await editor.press("ControlOrMeta+A");
|
|
await editor.pressSequentially(content);
|
|
}
|
|
|
|
export async function selectTabFromSwitcher(page: Page, label: string): Promise<void> {
|
|
await page
|
|
.getByRole("button", { name: /Switch tabs/ })
|
|
.filter({ visible: true })
|
|
.click();
|
|
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
|
|
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
|
|
await expect(option).toBeVisible();
|
|
await option.click();
|
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
|
}
|
|
|
|
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
|
|
export async function expectOneSheetOpen(page: Page): Promise<void> {
|
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
|
}
|
|
|
|
export async function expectNoSheetOpen(page: Page): Promise<void> {
|
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
|
}
|
|
|
|
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
|
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
|
await page
|
|
.getByRole("button", { name: "Bottom sheet backdrop" })
|
|
.last()
|
|
.click({ position: { x: 10, y: 10 } });
|
|
}
|
|
|
|
/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
|
|
export async function expectDeclinedDiscardPrompt(
|
|
page: Page,
|
|
close: () => Promise<void>,
|
|
): Promise<void> {
|
|
const prompt = page.waitForEvent("dialog").then(async (dialog) => {
|
|
const message = dialog.message();
|
|
await dialog.dismiss();
|
|
return message;
|
|
});
|
|
await close();
|
|
expect(await prompt).toContain("discard the draft");
|
|
}
|
|
|
|
/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
|
|
export function promptMentioning(target: string): string {
|
|
return [
|
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
|
"Return JSON only with fields 'title' and 'branch'.",
|
|
"",
|
|
"<user-prompt>",
|
|
`Open \`${target}\` now`,
|
|
"</user-prompt>",
|
|
].join("\n");
|
|
}
|