mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Pointer submission blurs the editor before sending and clears retained focus intent. Restore focus through the existing default send action for mouse-driven layouts, while keeping touch keyboard dismissal intact. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
import { expect, test, type Page } from "../support/fixtures";
|
|
import {
|
|
pasteRichTextIntoComposer,
|
|
fillComposerDraft,
|
|
copyLatestTurnIntoComposer,
|
|
selectComposerDraftRange,
|
|
undoComposerEdit,
|
|
redoComposerEdit,
|
|
blurComposerInput,
|
|
roundTripComposerTabBeforePaint,
|
|
composerLocator,
|
|
enterComposerFullscreen,
|
|
exitComposerFullscreen,
|
|
expectComposerDraft,
|
|
expectComposerFocused,
|
|
expectComposerFullscreenControls,
|
|
expectComposerNotFocused,
|
|
expectComposerVisible,
|
|
expectWorkspaceHeaderVisible,
|
|
resizeComposerViewport,
|
|
submitMessage,
|
|
submitMessageWithButton,
|
|
typeIntoFocusedComposer,
|
|
} from "../support/helpers/composer";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import {
|
|
closeMobileAgentSidebar,
|
|
closeMobileExplorer,
|
|
expectMobileAgentSidebarHidden,
|
|
expectMobileAgentSidebarVisible,
|
|
expectMobileExplorerHidden,
|
|
expectMobileExplorerVisible,
|
|
openMobileAgentSidebarWithKeyboard,
|
|
swipeOpenMobileExplorer,
|
|
} from "../support/helpers/sidebar";
|
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
|
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
|
import {
|
|
expectTimelinePromptPositionPreserved,
|
|
rememberTimelinePromptPosition,
|
|
seedLongMockAgentTimeline,
|
|
waitForTimelineGeometryToSettle,
|
|
} from "../support/helpers/timeline-pagination";
|
|
|
|
test("clicking chat text or background focuses the feed for keyboard paging", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const agent = await seedLongMockAgentTimeline({ turns: 8 });
|
|
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
const composer = composerLocator(page);
|
|
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
|
await composer.fill("Keep this draft");
|
|
await feed.getByText(agent.newestPrompt, { exact: true }).click();
|
|
await expect(feed).toBeFocused();
|
|
|
|
const beforePaging = await readScrollMetrics(page);
|
|
expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
|
|
await page.keyboard.press("PageUp");
|
|
await expect
|
|
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
|
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("chat-keyboard-paging.png"),
|
|
});
|
|
await page.keyboard.press("PageDown");
|
|
await expect
|
|
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
|
.toBeLessThanOrEqual(2);
|
|
|
|
await composer.click();
|
|
await expectComposerFocused(page);
|
|
await feed.click({ position: { x: 2, y: 40 } });
|
|
await expect(feed).toBeFocused();
|
|
await page.keyboard.press("PageUp");
|
|
await expect
|
|
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
|
.toBeGreaterThan(beforePaging.viewportHeight / 2);
|
|
await waitForTimelineGeometryToSettle(page);
|
|
const readingPrompt = agent.prompts[4];
|
|
await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
|
|
const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
|
|
await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
// The daemon finishing does not mean its response has reached the browser.
|
|
await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
|
|
await waitForTimelineGeometryToSettle(page);
|
|
await expectTimelinePromptPositionPreserved(page, readingPosition);
|
|
await expectComposerDraft(page, "Keep this draft");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("focusing the feed preserves assistant text selection and code copy controls", async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
const sentence = "Select this assistant sentence.";
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "chat-focus-selection-",
|
|
title: "Chat focus selection",
|
|
initialPrompt: "Render text and code.",
|
|
featureValues: {
|
|
mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
await openAgentRoute(page, agent);
|
|
await composerLocator(page).fill("Keep this draft");
|
|
|
|
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
|
|
await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
|
|
await expect(feed).toBeFocused();
|
|
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
|
|
await page.keyboard.press("ControlOrMeta+c");
|
|
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
|
|
|
|
const code = feed.locator('[data-paseo-markdown-language="bash"]');
|
|
await code.hover();
|
|
await code.locator("[data-paseo-markdown-ignore]").click();
|
|
await expect(feed).not.toBeFocused();
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
|
|
await expectComposerDraft(page, "Keep this draft");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
|
const copiedTable =
|
|
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-focus-",
|
|
title: "Composer focus",
|
|
featureValues: { mockAssistantResponse: copiedTable },
|
|
});
|
|
|
|
const connection = await installDaemonWebSocketGate(page);
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
|
|
await submitMessageWithButton(page, "Pointer submission");
|
|
await expectComposerDraft(page, "");
|
|
await expectComposerFocused(page);
|
|
await typeIntoFocusedComposer(page, "Ready after Send");
|
|
await expectComposerDraft(page, "Ready after Send");
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
await expectComposerFocused(page);
|
|
|
|
await submitMessage(page, "First message");
|
|
await expectComposerFocused(page);
|
|
|
|
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
|
|
await selectComposerDraftRange(page, 13, 25);
|
|
await pasteRichTextIntoComposer({
|
|
page,
|
|
html: '<a href="https://example.com/issues/39150">this FR</a>',
|
|
text: "this FR",
|
|
});
|
|
await expectComposerDraft(
|
|
page,
|
|
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
|
);
|
|
await undoComposerEdit(page);
|
|
await expectComposerDraft(page, "Investigate: replace this. Thanks");
|
|
await redoComposerEdit(page);
|
|
await expectComposerDraft(
|
|
page,
|
|
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
|
|
);
|
|
await typeIntoFocusedComposer(page, " next");
|
|
await expectComposerDraft(
|
|
page,
|
|
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
|
|
);
|
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
|
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
|
|
await connection.drop();
|
|
await connection.waitForBlockedConnection();
|
|
await pasteRichTextIntoComposer({
|
|
page,
|
|
html: '<a href="https://example.com/offline">offline link</a>',
|
|
text: "offline link",
|
|
});
|
|
await expectComposerDraft(
|
|
page,
|
|
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
|
|
);
|
|
await fillComposerDraft(page, "");
|
|
await pasteRichTextIntoComposer({
|
|
page,
|
|
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
|
text: "Resource\tStatus\nIssue\tclosed",
|
|
});
|
|
await expectComposerDraft(
|
|
page,
|
|
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
|
);
|
|
await fillComposerDraft(page, "");
|
|
await pasteRichTextIntoComposer({
|
|
page,
|
|
html: '<a href="https://example.com/">Issue</a><table></table>',
|
|
text: "Issue",
|
|
});
|
|
await expectComposerDraft(page, "[Issue](https://example.com/)");
|
|
await copyLatestTurnIntoComposer(page);
|
|
await expectComposerDraft(page, copiedTable);
|
|
await expectComposerFocused(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-focus-open-",
|
|
title: "Composer focus on open",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerFocused(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
|
|
await page.evaluate(async (frames) => {
|
|
for (let frame = 0; frame < frames; frame += 1) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
}
|
|
}, count);
|
|
}
|
|
|
|
test.describe("touch screen wide enough for the desktop layout", () => {
|
|
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
|
// composer there raises the on-screen keyboard over the conversation.
|
|
test.use({
|
|
viewport: { width: 1024, height: 768 },
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
});
|
|
|
|
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-touch-remount-",
|
|
title: "Touch remount",
|
|
});
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await composerLocator(page).fill("Keep this draft through resize");
|
|
await expectComposerFocused(page);
|
|
await resizeComposerViewport(page, "portrait");
|
|
await expectComposerDraft(page, "Keep this draft through resize");
|
|
await expectComposerNotFocused(page);
|
|
await composerLocator(page).tap();
|
|
await expectComposerFocused(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({
|
|
repoPrefix: "composer-touch-retained-",
|
|
});
|
|
try {
|
|
const first = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "First touch chat",
|
|
});
|
|
const second = await createMockIdleAgent(workspace.client, {
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Second touch chat",
|
|
});
|
|
await openWorkspaceWithAgents(page, [first, second]);
|
|
// Repeat after an explicit edit so retained focus from an earlier visit is
|
|
// also dismissed when the same mounted editor becomes inactive again.
|
|
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
|
|
await composerLocator(page).fill(draft);
|
|
await expectComposerFocused(page);
|
|
// An external keyboard changes tabs without a pointer click blurring the
|
|
// editor first. The device still has a touch-primary focus policy.
|
|
await roundTripComposerTabBeforePaint(page, "Second touch chat");
|
|
await expectComposerDraft(page, draft);
|
|
await expectComposerNotFocused(page);
|
|
await composerLocator(page).tap();
|
|
await expectComposerFocused(page);
|
|
await typeIntoFocusedComposer(page, " after return");
|
|
await expectComposerDraft(page, `${draft} after return`);
|
|
}
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-focus-touch-",
|
|
title: "Composer focus on touch",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
expect(
|
|
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
|
|
).toBe(false);
|
|
// Autofocus makes its first attempt on the next animation frame.
|
|
await waitForAnimationFrames(page, 4);
|
|
await expectComposerNotFocused(page);
|
|
await submitMessageWithButton(page, "Touch submission");
|
|
await expectComposerDraft(page, "");
|
|
await expectComposerNotFocused(page);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
const COMPACT_PANELS = [
|
|
{
|
|
name: "agent list",
|
|
open: openMobileAgentSidebarWithKeyboard,
|
|
expectOpen: expectMobileAgentSidebarVisible,
|
|
close: closeMobileAgentSidebar,
|
|
expectClosed: expectMobileAgentSidebarHidden,
|
|
},
|
|
{
|
|
name: "Explorer",
|
|
open: swipeOpenMobileExplorer,
|
|
expectOpen: expectMobileExplorerVisible,
|
|
close: closeMobileExplorer,
|
|
expectClosed: expectMobileExplorerHidden,
|
|
},
|
|
] as const;
|
|
|
|
for (const panel of COMPACT_PANELS) {
|
|
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
|
|
const agent = await seedMockAgentWorkspace({
|
|
repoPrefix: "composer-focus-panel-",
|
|
title: `Composer focus under ${panel.name}`,
|
|
});
|
|
const draft = "Line one\nLine two\nLine three";
|
|
try {
|
|
await resizeComposerViewport(page, "portrait");
|
|
await openAgentRoute(page, agent);
|
|
await expectComposerVisible(page);
|
|
|
|
await test.step("an untouched editor stays untouched", async () => {
|
|
await expectComposerNotFocused(page);
|
|
await panel.open(page);
|
|
await panel.expectOpen(page);
|
|
await panel.close(page);
|
|
await panel.expectClosed(page);
|
|
await expectComposerNotFocused(page);
|
|
});
|
|
|
|
await test.step("an inline editor blurred under the panel stays blurred", async () => {
|
|
await composerLocator(page).fill(draft);
|
|
await composerLocator(page).click();
|
|
await expectComposerFocused(page);
|
|
await panel.open(page);
|
|
await panel.expectOpen(page);
|
|
await blurComposerInput(page);
|
|
await panel.close(page);
|
|
await panel.expectClosed(page);
|
|
await expectComposerNotFocused(page);
|
|
await expectComposerDraft(page, draft);
|
|
});
|
|
|
|
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
|
|
await composerLocator(page).click();
|
|
await enterComposerFullscreen(page);
|
|
await expectComposerFullscreenControls(page);
|
|
await expectComposerFocused(page);
|
|
await panel.open(page);
|
|
await panel.expectOpen(page);
|
|
await blurComposerInput(page);
|
|
await panel.close(page);
|
|
await panel.expectClosed(page);
|
|
await expectComposerNotFocused(page);
|
|
await expectComposerFullscreenControls(page);
|
|
await expectComposerDraft(page, draft);
|
|
await exitComposerFullscreen(page);
|
|
await expectWorkspaceHeaderVisible(page);
|
|
});
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
}
|