authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 22:10:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 22:10:21-07:00
log798e945aa77939937464ff45deaa8947be8d402f
treeda70460c8b9fee59af4fe3b8a915037f72aa2988
parent63f74e209dc27df1f8733535d6fd4a79d442add8
signature Signed by PGP key B5690EEEBB952194

fix(preview): agent browser clicks no longer steal your composer focus (#15008)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 225 insertions(+), 3 deletions(-)

apps/web/src/components/preview/PreviewAutomationHosts.tsx+6-3
...@@ -90,6 +90,7 @@ import {...@@ -90,6 +90,7 @@ import {
90 resolvePreviewAutomationTarget,90 resolvePreviewAutomationTarget,
91} from "./previewAutomationTarget";91} from "./previewAutomationTarget";
92import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget";92import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget";
93import { runPreviewClickKeepingHostFocus } from "./previewClickFocus";
93import { isPreviewViewportReady } from "./previewViewportReadiness";94import { isPreviewViewportReady } from "./previewViewportReadiness";
94import { shouldRollbackPreviewViewport } from "./previewViewportRollback";95import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
9596
...@@ -686,9 +687,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -686,9 +687,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
686 }687 }
687 case "click": {688 case "click": {
688 const ready = await requireReadyTab();689 const ready = await requireReadyTab();
689 return await ready.bridge.automation.click(690 return await runPreviewClickKeepingHostFocus(ready.runtimeTabId, () =>
690 ready.runtimeTabId,691 ready.bridge.automation.click(
691 request.input as Parameters<typeof ready.bridge.automation.click>[1],692 ready.runtimeTabId,
693 request.input as Parameters<typeof ready.bridge.automation.click>[1],
694 ),
692 );695 );
693 }696 }
694 case "type": {697 case "type": {
apps/web/src/components/preview/previewClickFocus.test.ts created+152
...@@ -0,0 +1,152 @@
1// @vitest-environment jsdom
2
3import { afterEach, describe, expect, it } from "vite-plus/test";
4
5import { runPreviewClickKeepingHostFocus } from "./previewClickFocus";
6
7const TAB = "runtime-tab";
8
9const mount = (tagName: string, previewTab?: string) => {
10 const element = document.createElement(tagName);
11 element.tabIndex = -1;
12 if (previewTab) element.setAttribute("data-preview-tab", previewTab);
13 document.body.append(element);
14 return element;
15};
16
17afterEach(() => {
18 document.body.replaceChildren();
19});
20
21describe("runPreviewClickKeepingHostFocus", () => {
22 it("gives focus back to the composer after the click focuses the page", async () => {
23 const composer = mount("textarea");
24 const webview = mount("webview", TAB);
25 composer.focus();
26
27 const result = await runPreviewClickKeepingHostFocus(TAB, async () => {
28 webview.focus();
29 return "clicked";
30 });
31
32 expect(result).toBe("clicked");
33 expect(document.activeElement).toBe(composer);
34 });
35
36 it("gives focus back when the user's typing interrupts the click", async () => {
37 const composer = mount("textarea");
38 const webview = mount("webview", TAB);
39 composer.focus();
40 const interrupted = new Error("PreviewAutomationControlInterruptedError");
41
42 await expect(
43 runPreviewClickKeepingHostFocus(TAB, async () => {
44 webview.focus();
45 throw interrupted;
46 }),
47 ).rejects.toBe(interrupted);
48
49 expect(document.activeElement).toBe(composer);
50 });
51
52 it("gives focus back once overlapping clicks in two tabs finish", async () => {
53 const composer = mount("textarea");
54 const first = mount("webview", TAB);
55 const second = mount("webview", "other-tab");
56 composer.focus();
57 let finishSecond = () => {};
58
59 const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => {
60 first.focus();
61 });
62 // Starts while the first click holds focus in its page, and finishes last.
63 const secondClick = runPreviewClickKeepingHostFocus("other-tab", async () => {
64 second.focus();
65 await new Promise<void>((resolve) => {
66 finishSecond = resolve;
67 });
68 });
69 await firstClick;
70 finishSecond();
71 await secondClick;
72
73 expect(document.activeElement).toBe(composer);
74 });
75
76 it("keeps the user's page when overlapping clicks finish in reverse order", async () => {
77 const first = mount("webview", TAB);
78 const second = mount("webview", "other-tab");
79 second.focus();
80 let finishFirst = () => {};
81
82 const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => {
83 first.focus();
84 await new Promise<void>((resolve) => {
85 finishFirst = resolve;
86 });
87 });
88 // Starts while the first click holds focus in its page, and finishes first.
89 await runPreviewClickKeepingHostFocus("other-tab", async () => {
90 second.focus();
91 });
92 finishFirst();
93 await firstClick;
94
95 expect(document.activeElement).toBe(second);
96 });
97
98 it("ignores a click that never finishes", async () => {
99 const composer = mount("textarea");
100 const stuck = mount("webview", "stuck-tab");
101 const webview = mount("webview", TAB);
102 const other = mount("webview", "other-tab");
103 composer.focus();
104
105 // A click queued behind a page promise that never settles.
106 void runPreviewClickKeepingHostFocus("stuck-tab", () => new Promise<never>(() => {}));
107 await runPreviewClickKeepingHostFocus(TAB, async () => {
108 webview.focus();
109 });
110 expect(document.activeElement).toBe(composer);
111
112 // The user opens the stuck tab's page themselves.
113 stuck.focus();
114 await runPreviewClickKeepingHostFocus("other-tab", async () => {
115 other.focus();
116 });
117 expect(document.activeElement).toBe(stuck);
118 });
119
120 it("leaves no focus in the page when the composer can no longer take focus", async () => {
121 const composer = mount("div");
122 const webview = mount("webview", TAB);
123 composer.focus();
124
125 await runPreviewClickKeepingHostFocus(TAB, async () => {
126 webview.focus();
127 // Like the editor going read-only for an approval request.
128 composer.removeAttribute("tabindex");
129 });
130
131 expect(document.activeElement).toBe(document.body);
132 });
133
134 it("leaves no focus in the page when nothing in the app had focus", async () => {
135 const webview = mount("webview", TAB);
136
137 await runPreviewClickKeepingHostFocus(TAB, async () => {
138 webview.focus();
139 });
140
141 expect(document.activeElement).toBe(document.body);
142 });
143
144 it("keeps focus in the page when the user was already typing there", async () => {
145 const webview = mount("webview", TAB);
146 webview.focus();
147
148 await runPreviewClickKeepingHostFocus(TAB, async () => undefined);
149
150 expect(document.activeElement).toBe(webview);
151 });
152});
apps/web/src/components/preview/previewClickFocus.ts created+67
...@@ -0,0 +1,67 @@
1interface PreviewClick {
2 readonly runtimeTabId: string;
3 readonly order: number;
4 previous: Element | null;
5}
6
7// Agent clicks can overlap across tabs and threads.
8const inFlightClicks = new Set<PreviewClick>();
9let nextClickOrder = 0;
10
11const focusedPreviewTab = (element: Element | null): string | null =>
12 element?.localName === "webview" ? element.getAttribute("data-preview-tab") : null;
13
14const restoreHostFocus = ({ runtimeTabId, previous }: PreviewClick): void => {
15 const current = document.activeElement;
16 if (
17 !(current instanceof HTMLElement) ||
18 current === previous ||
19 focusedPreviewTab(current) !== runtimeTabId
20 ) {
21 return;
22 }
23 if (previous instanceof HTMLElement && previous.isConnected && previous !== document.body) {
24 previous.focus({ preventScroll: true });
25 }
26 // The previous element may no longer take focus, like a composer that was
27 // disabled during the click. Never leave focus in the page.
28 if (document.activeElement === current) current.blur();
29};
30
31/**
32 * Runs an agent click in a preview tab and hands keyboard focus back to the
33 * host page afterwards.
34 *
35 * The desktop dispatches the click as a CDP mouse press, which focuses the
36 * guest page, and nothing gives that focus back. The user's next keystrokes
37 * then go into the page, even when its tab is hidden or belongs to another
38 * thread. Only the host document knows what had focus before the click, so
39 * the restore happens here and not in the desktop main process.
40 */
41export async function runPreviewClickKeepingHostFocus<A>(
42 runtimeTabId: string,
43 click: () => Promise<A>,
44): Promise<A> {
45 const entry: PreviewClick = {
46 runtimeTabId,
47 order: nextClickOrder++,
48 previous: document.activeElement,
49 };
50 inFlightClicks.add(entry);
51 try {
52 return await click();
53 } finally {
54 // Also runs when the click fails: a keystroke that reaches the page after
55 // the press counts as human input and fails the click as interrupted.
56 inFlightClicks.delete(entry);
57 // A click that started while this one held focus in its page saw that page
58 // as what had focus. Hand it this click's target instead. A click that never
59 // finishes never gets here, so it cannot override where the user goes later.
60 for (const other of inFlightClicks) {
61 if (other.order > entry.order && focusedPreviewTab(other.previous) === runtimeTabId) {
62 other.previous = entry.previous;
63 }
64 }
65 restoreHostFocus(entry);
66 }
67}