From 2675bc84f312286b0f9704a9b5da3ec17ffe635c Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:47:33 +0200 Subject: [PATCH] fix(desktop): hand keyboard focus back after an automation click (upstream #10982) Imported from pingdotgg/t3code#10982 (open upstream, by Mnigos), squashing its three commits 5d24187dd3, b577a8380f and a2e44cd6c2. Fixes upstream issue #10980. preview_click dispatches CDP mousePressed into the guest WebContents, which moves the app's keyboard focus into the previewed page and never returns it, so the user's typing lands in the page (even for hidden open:false tabs). The click path now restores the previously focused WebContents through a restoreFocusedWebContents helper shared with preview_press, on success and failure, yielding when focus moved elsewhere or the user left the app. Imported unchanged: apps/desktop/src/preview/Manager.ts and Manager.test.ts (test hunk applied with a 3-way merge; no adaptation needed). Co-Authored-By: Mnigos Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 107 ++++++++++++++++++++++- apps/desktop/src/preview/Manager.ts | 54 +++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 304efeafefe9..a8c03ec91ea5 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -185,6 +185,7 @@ const { createFromPath, fromId, getFocusedWebContents, + getFocusedWindow, mkdir, showItemInFolder, webviewSend, @@ -192,6 +193,7 @@ const { writeClipboard, } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), + getFocusedWindow: vi.fn<() => Electron.BrowserWindow | null>(() => null), clipboardItemConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean; readonly toPNG: () => Buffer } => ({ isEmpty: () => false, @@ -207,7 +209,7 @@ const { })); vi.mock("electron", () => ({ - BrowserWindow: browserWindowConstructor, + BrowserWindow: Object.assign(browserWindowConstructor, { getFocusedWindow }), ClipboardItem: class { constructor(data: Record) { clipboardItemConstructor(data); @@ -546,6 +548,8 @@ describe("PreviewManager", () => { fromId.mockClear(); getFocusedWebContents.mockReset(); getFocusedWebContents.mockReturnValue(null); + getFocusedWindow.mockReset(); + getFocusedWindow.mockReturnValue({} as Electron.BrowserWindow); mkdir.mockClear(); writeFile.mockClear(); showItemInFolder.mockClear(); @@ -4119,6 +4123,107 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "hands keyboard focus back to the previous renderer after an automation click", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { result: { value: { width: 800, height: 600 } } }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + const restoreFocus = vi.fn(); + getFocusedWebContents.mockReturnValue({ + id: 7, + isDestroyed: () => false, + focus: restoreFocus, + } as never); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const click = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(click); + + expect(restoreFocus).toHaveBeenCalledTimes(1); + expect(restoreFocus.mock.invocationCallOrder[0]).toBeGreaterThan( + sendCommand.mock.invocationCallOrder.at(-1) ?? 0, + ); + + const offscreen = yield* manager + .automationClick("tab_1", { x: 5000, y: 80 }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + expect((yield* Fiber.join(offscreen))._tag).toBe("Failure"); + expect(restoreFocus).toHaveBeenCalledTimes(2); + + // Focus that moved to a third renderer while the click ran is left alone. + getFocusedWebContents + .mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never) + .mockReturnValue({ id: 9, isDestroyed: () => false, focus: vi.fn() } as never); + const moved = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(moved); + expect(restoreFocus).toHaveBeenCalledTimes(2); + + // The user switched to another app while the click ran: T3 has no focused + // window and no focused renderer, so nothing pulls them back. + getFocusedWebContents + .mockReturnValueOnce({ id: 7, isDestroyed: () => false, focus: restoreFocus } as never) + .mockReturnValue(null); + getFocusedWindow.mockReturnValue(null); + const left = yield* manager + .automationClick("tab_1", { x: 120, y: 80 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(200); + yield* Fiber.join(left); + expect(restoreFocus).toHaveBeenCalledTimes(2); + }), + ), + ); + effectIt.effect("types in background webviews and enables native key input", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 468c47065315..b3c87d2a9b12 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -3848,7 +3848,59 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + // Dispatching input moves keyboard focus into the guest renderer as a side + // effect. Hand it back to whatever had it before, so the user's next keystroke + // does not land in the previewed page, which may not even be visible. + const restoreFocusedWebContents = Effect.fn("PreviewManager.restoreFocusedWebContents")( + function* ( + operation: string, + tabId: string, + wc: Electron.WebContents, + previouslyFocused: Electron.WebContents | null, + ) { + if (!previouslyFocused || previouslyFocused.id === wc.id || previouslyFocused.isDestroyed()) { + return; + } + // A newer selection the user made while the action ran wins over the restore. + const focusedNow = yield* attempt({ operation, tabId, webContentsId: wc.id }, () => + webContents.getFocusedWebContents(), + ).pipe(Effect.orElseSucceed(() => null)); + if (focusedNow && focusedNow.id !== wc.id && focusedNow.id !== previouslyFocused.id) { + return; + } + // The user left T3 for another app while the action ran; do not pull them back. + if (focusedNow === null && BrowserWindow.getFocusedWindow() === null) { + return; + } + yield* attempt({ operation, tabId, webContentsId: previouslyFocused.id }, () => + previouslyFocused.focus(), + ).pipe(Effect.ignore); + }, + ); + const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* ( + tabId: string, + wc: Electron.WebContents, + input: PreviewAutomationClickInput, + send: SendCommand, + ) { + const previouslyFocused = yield* attempt( + { operation: "automationClick.getFocusedWebContents", tabId, webContentsId: wc.id }, + () => webContents.getFocusedWebContents(), + ); + yield* dispatchAutomationClick(tabId, input, send).pipe( + Effect.ensuring( + restoreFocusedWebContents( + "automationClick.restoreFocusedWebContents", + tabId, + wc, + previouslyFocused, + ), + ), + ); + }); + + const dispatchAutomationClick = Effect.fn("PreviewManager.dispatchAutomationClick")(function* ( tabId: string, input: PreviewAutomationClickInput, send: SendCommand, @@ -3911,7 +3963,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const wc = yield* requireWebContents(tabId); yield* withControlSession(tabId, wc, "click", (send) => - performAutomationClick(tabId, input, send), + performAutomationClick(tabId, wc, input, send), ); });