From d9b874f9d0d40195ada83ddce6ab43030a47675d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:14:23 +0200 Subject: [PATCH] Scroll chat with Page Up and Page Down after clicking messages (#7033) * fix(app): focus chat feed when clicking message content * test(app): wait for new chat output before checking scroll position Co-authored-by: Ivan Pantic * test(app): capture the keyboard paging regression * test(app): verify paged chat text stays in place --------- Co-authored-by: Ivan Pantic Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/composer-focus.spec.ts | 96 ++++++++++++++++++- .../support/helpers/timeline-pagination.ts | 2 +- .../app/src/agent-stream/strategy-web.tsx | 2 + .../desktop/e2e/workspace-restart.spec.ts | 2 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index cbc710a94a..9119791806 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "../support/fixtures"; import { - composerLocator, blurComposerInput, + composerLocator, enterComposerFullscreen, exitComposerFullscreen, expectComposerDraft, @@ -25,6 +25,100 @@ import { swipeOpenMobileAgentSidebar, swipeOpenMobileExplorer, } from "../support/helpers/sidebar"; +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 agent = await seedMockAgentWorkspace({ diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 7dd3dca8d7..52027df956 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -544,7 +544,7 @@ async function readTimelineViewport(page: Page): Promise { +export async function waitForTimelineGeometryToSettle(page: Page): Promise { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.evaluate( (element) => diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 226b7c1e2a..f1c806fdca 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1298,6 +1298,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool