From d04de2be5e33e08bf7d425459b1d6908763cfbf8 Mon Sep 17 00:00:00 2001 From: akosbalogh Date: Fri, 24 Jul 2026 17:05:03 +0200 Subject: [PATCH] fix(desktop): route mouse thumb buttons to the in-app browser When the pointer hovers the preview without focusing it, Chromium routes back/forward thumb-button navigation to the focused host renderer, so the app router navigated instead of the browser. Intercept the thumb buttons (button 3 = back, 4 = forward) in the guest preload, suppress Chromium's default, and forward an explicit navigate request to PreviewManager so the buttons always drive the hovered browser's history and never the host app. Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 19 +++++++ apps/desktop/src/preview/PickPreload.ts | 35 +++++++++++++ 4 files changed, 118 insertions(+) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a476..e63597b71ef 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f1215ee7b60..65b681c1c47 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -652,6 +652,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6c942d4ccb9..92b5700708e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -60,6 +60,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -1208,6 +1209,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1242,6 +1259,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1253,6 +1271,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b898102..16416379b02 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -20,6 +20,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -100,6 +101,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`;