diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 173a027e9df263ceea9610cc5a590d491b271f82..4a3f9b7c88ac647d6d8aa4cdb887cb441dddb69b 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -90,6 +90,7 @@ import { resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget"; +import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; import { isPreviewViewportReady } from "./previewViewportReadiness"; import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; @@ -686,9 +687,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "click": { const ready = await requireReadyTab(); - return await ready.bridge.automation.click( - ready.runtimeTabId, - request.input as Parameters[1], + return await runPreviewClickKeepingHostFocus(ready.runtimeTabId, () => + ready.bridge.automation.click( + ready.runtimeTabId, + request.input as Parameters[1], + ), ); } case "type": { diff --git a/apps/web/src/components/preview/previewClickFocus.test.ts b/apps/web/src/components/preview/previewClickFocus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..559872c2ace4e8f3209bd8b007c323fb28f679d7 --- /dev/null +++ b/apps/web/src/components/preview/previewClickFocus.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; + +const TAB = "runtime-tab"; + +const mount = (tagName: string, previewTab?: string) => { + const element = document.createElement(tagName); + element.tabIndex = -1; + if (previewTab) element.setAttribute("data-preview-tab", previewTab); + document.body.append(element); + return element; +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("runPreviewClickKeepingHostFocus", () => { + it("gives focus back to the composer after the click focuses the page", async () => { + const composer = mount("textarea"); + const webview = mount("webview", TAB); + composer.focus(); + + const result = await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + return "clicked"; + }); + + expect(result).toBe("clicked"); + expect(document.activeElement).toBe(composer); + }); + + it("gives focus back when the user's typing interrupts the click", async () => { + const composer = mount("textarea"); + const webview = mount("webview", TAB); + composer.focus(); + const interrupted = new Error("PreviewAutomationControlInterruptedError"); + + await expect( + runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + throw interrupted; + }), + ).rejects.toBe(interrupted); + + expect(document.activeElement).toBe(composer); + }); + + it("gives focus back once overlapping clicks in two tabs finish", async () => { + const composer = mount("textarea"); + const first = mount("webview", TAB); + const second = mount("webview", "other-tab"); + composer.focus(); + let finishSecond = () => {}; + + const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { + first.focus(); + }); + // Starts while the first click holds focus in its page, and finishes last. + const secondClick = runPreviewClickKeepingHostFocus("other-tab", async () => { + second.focus(); + await new Promise((resolve) => { + finishSecond = resolve; + }); + }); + await firstClick; + finishSecond(); + await secondClick; + + expect(document.activeElement).toBe(composer); + }); + + it("keeps the user's page when overlapping clicks finish in reverse order", async () => { + const first = mount("webview", TAB); + const second = mount("webview", "other-tab"); + second.focus(); + let finishFirst = () => {}; + + const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { + first.focus(); + await new Promise((resolve) => { + finishFirst = resolve; + }); + }); + // Starts while the first click holds focus in its page, and finishes first. + await runPreviewClickKeepingHostFocus("other-tab", async () => { + second.focus(); + }); + finishFirst(); + await firstClick; + + expect(document.activeElement).toBe(second); + }); + + it("ignores a click that never finishes", async () => { + const composer = mount("textarea"); + const stuck = mount("webview", "stuck-tab"); + const webview = mount("webview", TAB); + const other = mount("webview", "other-tab"); + composer.focus(); + + // A click queued behind a page promise that never settles. + void runPreviewClickKeepingHostFocus("stuck-tab", () => new Promise(() => {})); + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + }); + expect(document.activeElement).toBe(composer); + + // The user opens the stuck tab's page themselves. + stuck.focus(); + await runPreviewClickKeepingHostFocus("other-tab", async () => { + other.focus(); + }); + expect(document.activeElement).toBe(stuck); + }); + + it("leaves no focus in the page when the composer can no longer take focus", async () => { + const composer = mount("div"); + const webview = mount("webview", TAB); + composer.focus(); + + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + // Like the editor going read-only for an approval request. + composer.removeAttribute("tabindex"); + }); + + expect(document.activeElement).toBe(document.body); + }); + + it("leaves no focus in the page when nothing in the app had focus", async () => { + const webview = mount("webview", TAB); + + await runPreviewClickKeepingHostFocus(TAB, async () => { + webview.focus(); + }); + + expect(document.activeElement).toBe(document.body); + }); + + it("keeps focus in the page when the user was already typing there", async () => { + const webview = mount("webview", TAB); + webview.focus(); + + await runPreviewClickKeepingHostFocus(TAB, async () => undefined); + + expect(document.activeElement).toBe(webview); + }); +}); diff --git a/apps/web/src/components/preview/previewClickFocus.ts b/apps/web/src/components/preview/previewClickFocus.ts new file mode 100644 index 0000000000000000000000000000000000000000..9009568a4712087dd20044cab99d859f518396c3 --- /dev/null +++ b/apps/web/src/components/preview/previewClickFocus.ts @@ -0,0 +1,67 @@ +interface PreviewClick { + readonly runtimeTabId: string; + readonly order: number; + previous: Element | null; +} + +// Agent clicks can overlap across tabs and threads. +const inFlightClicks = new Set(); +let nextClickOrder = 0; + +const focusedPreviewTab = (element: Element | null): string | null => + element?.localName === "webview" ? element.getAttribute("data-preview-tab") : null; + +const restoreHostFocus = ({ runtimeTabId, previous }: PreviewClick): void => { + const current = document.activeElement; + if ( + !(current instanceof HTMLElement) || + current === previous || + focusedPreviewTab(current) !== runtimeTabId + ) { + return; + } + if (previous instanceof HTMLElement && previous.isConnected && previous !== document.body) { + previous.focus({ preventScroll: true }); + } + // The previous element may no longer take focus, like a composer that was + // disabled during the click. Never leave focus in the page. + if (document.activeElement === current) current.blur(); +}; + +/** + * Runs an agent click in a preview tab and hands keyboard focus back to the + * host page afterwards. + * + * The desktop dispatches the click as a CDP mouse press, which focuses the + * guest page, and nothing gives that focus back. The user's next keystrokes + * then go into the page, even when its tab is hidden or belongs to another + * thread. Only the host document knows what had focus before the click, so + * the restore happens here and not in the desktop main process. + */ +export async function runPreviewClickKeepingHostFocus( + runtimeTabId: string, + click: () => Promise, +): Promise { + const entry: PreviewClick = { + runtimeTabId, + order: nextClickOrder++, + previous: document.activeElement, + }; + inFlightClicks.add(entry); + try { + return await click(); + } finally { + // Also runs when the click fails: a keystroke that reaches the page after + // the press counts as human input and fails the click as interrupted. + inFlightClicks.delete(entry); + // A click that started while this one held focus in its page saw that page + // as what had focus. Hand it this click's target instead. A click that never + // finishes never gets here, so it cannot override where the user goes later. + for (const other of inFlightClicks) { + if (other.order > entry.order && focusedPreviewTab(other.previous) === runtimeTabId) { + other.previous = entry.previous; + } + } + restoreHostFocus(entry); + } +}