Files
paseo/packages/app/e2e/browser/composer-focus.spec.ts
T
paseo-bot[bot] 0e40022519 Preserve linked resources when pasting prompts (#8972)
* fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.

* fix(app): keep pasted links undoable while drafting offline

Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads.

* fix(app): preserve copied Markdown with normalized links

Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact.

* fix(app): retain external linked tables when pasting prompts

Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells.

* test(app): name rich clipboard paste arguments

* fix(app): preserve pasted links around rowless tables

The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations.

* test(app): wait for workspace restore before undo teardown

The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 07:04:10 +00:00

394 lines
14 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,
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 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 })).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);
} 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();
}
});
}