diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index a7316fe165..e8c4b279ae 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -5,6 +5,7 @@ import { seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; +import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectReconnectingToastGone, @@ -124,6 +125,49 @@ async function closeFind(page: Page) { .toEqual([]); } +async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") { + // The scrolling surface has no accessible name; use its existing test id. + const viewport = page.getByTestId("agent-chat-scroll"); + await waitForTimelineGeometryToSettle(page); + const before = await viewport.evaluate((element) => element.scrollTop); + await page.keyboard.press(key); + const direction = key === "PageUp" ? -1 : 1; + await expect + .poll( + async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before), + ) + .toBeGreaterThan(100); +} + +test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-scroll-focus-", + title: "Chat Find keyboard scrolling", + initialPrompt: "Render the response", + featureValues: { mockAssistantResponse: TALL_RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentRoute(page, agent); + await expect(page.getByTestId("assistant-message").first()).toBeVisible(); + await searchChat(page, "Closing paragraph"); + await expectHighlight(page, "Closing paragraph"); + await waitForTimelineGeometryToSettle(page); + await closeFind(page); + await expectChatKeyboardScroll(page, "PageUp"); + await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") }); + await expectChatKeyboardScroll(page, "PageDown"); + await searchChat(page, "Closing paragraph"); + await expectHighlight(page, "Closing paragraph"); + await waitForTimelineGeometryToSettle(page); + await page.getByRole("button", { name: "Close Find", exact: true }).click(); + await expect(query(page)).toHaveCount(0); + await expectChatKeyboardScroll(page, "PageUp"); + } finally { + await agent.cleanup(); + } +}); + test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({ page, }, testInfo) => { diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx index c817b55028..a083dc5d9e 100644 --- a/packages/app/src/agent-stream/chat-find/index.web.tsx +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -118,8 +118,8 @@ export function ChatFind({ }, [active, isInteractive, model]); const close = useCallback(() => { model.close(); - root.current?.focus({ preventScroll: true }); - }, [model]); + viewportRef.current?.focus?.(); + }, [model, viewportRef]); let status = ""; if (state.phase === "searching") status = t("paneFind.searching"); else if (state.phase === "loading") status = t("paneFind.loading"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 00974867e4..e57fc98e48 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { const handle: StreamViewportHandle = { + focus: () => scrollContainerRef.current?.focus({ preventScroll: true }), scrollToBottom: () => { setFollowOutput(true); cancelPendingStickToBottom(); diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f43f8f6b1..7604b48cdd 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence { } export interface StreamViewportHandle { + /** Give keyboard navigation back to the scrolling surface on web. */ + focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;