authorgravatar for hello.akosb@gmail.comAkos Balogh <hello.akosb@gmail.com> 2026-08-15 14:10:21+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:10:21+00:00
log7c8848ebb054c1f4c1279f634633cddc37cb1fac
treeb530b8d682f2e9f74bbfe2d5be2d1f94122fcd23
parentcf7bfd1c93974428262ab1419d11c972d01d65fa
signature Signed by PGP key B5690EEEBB952194

fix(desktop): route mouse thumb buttons to the in-app browser (#4459)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed, 118 insertions(+), 0 deletions(-)

apps/desktop/src/preview/GuestProtocol.ts+1
......@@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
44export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
55export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
66export const HUMAN_INPUT_CHANNEL = "preview:human-input";
7export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
apps/desktop/src/preview/Manager.test.ts+63
......@@ -2239,6 +2239,69 @@ describe("PreviewManager", () => {
22392239 ),
22402240 );
22412241
2242 effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
2243 withManager((manager) =>
2244 Effect.gen(function* () {
2245 let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined;
2246 const goBack = vi.fn();
2247 const goForward = vi.fn();
2248 let canGoBack = true;
2249 fromId.mockReturnValue({
2250 id: 42,
2251 isDestroyed: () => false,
2252 getType: () => "webview",
2253 getURL: () => "https://example.com",
2254 getTitle: () => "Example",
2255 isLoading: () => false,
2256 getZoomFactor: () => 1,
2257 setZoomFactor: vi.fn(),
2258 on: vi.fn(),
2259 off: vi.fn(),
2260 ipc: {
2261 on: vi.fn((channel: string, listener: typeof mouseNavigate) => {
2262 if (channel === "preview:mouse-navigate") mouseNavigate = listener;
2263 }),
2264 off: vi.fn(),
2265 },
2266 send: webviewSend,
2267 navigationHistory: {
2268 canGoBack: () => canGoBack,
2269 canGoForward: () => true,
2270 goBack,
2271 goForward,
2272 },
2273 setWindowOpenHandler: vi.fn(),
2274 debugger: {
2275 isAttached: () => false,
2276 attach: vi.fn(),
2277 sendCommand: vi.fn(async () => undefined),
2278 on: vi.fn(),
2279 off: vi.fn(),
2280 },
2281 } as never);
2282
2283 yield* manager.createTab("tab_nav");
2284 yield* manager.registerWebview("tab_nav", 42);
2285 expect(mouseNavigate).toBeDefined();
2286
2287 mouseNavigate?.({}, { direction: "back" });
2288 yield* Effect.yieldNow;
2289 expect(goBack).toHaveBeenCalledOnce();
2290
2291 mouseNavigate?.({}, { direction: "forward" });
2292 yield* Effect.yieldNow;
2293 expect(goForward).toHaveBeenCalledOnce();
2294
2295 // Ignores unknown payloads and never navigates when history is exhausted.
2296 mouseNavigate?.({}, { direction: "sideways" });
2297 canGoBack = false;
2298 mouseNavigate?.({}, { direction: "back" });
2299 yield* Effect.yieldNow;
2300 expect(goBack).toHaveBeenCalledOnce();
2301 }),
2302 ),
2303 );
2304
22422305 effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
22432306 withManager((manager) =>
22442307 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+19
......@@ -58,6 +58,7 @@ import {
5858 CANCEL_PICK_CHANNEL,
5959 ELEMENT_PICKED_CHANNEL,
6060 HUMAN_INPUT_CHANNEL,
61 MOUSE_NAVIGATE_CHANNEL,
6162 START_PICK_CHANNEL,
6263} from "./GuestProtocol.ts";
6364import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
......@@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15061507 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
15071508 runFork(handleHumanInput(rawSignal));
15081509 };
1510 const mouseNavigate = (_event: unknown, payload?: unknown): void => {
1511 const direction =
1512 typeof payload === "object" && payload !== null && "direction" in payload
1513 ? (payload as { direction?: unknown }).direction
1514 : undefined;
1515 if (direction !== "back" && direction !== "forward") return;
1516 runFork(
1517 attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => {
1518 if (direction === "back") {
1519 if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
1520 } else if (wc.navigationHistory.canGoForward()) {
1521 wc.navigationHistory.goForward();
1522 }
1523 }).pipe(Effect.ignore),
1524 );
1525 };
15091526 const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* (
15101527 event: Electron.Event,
15111528 input: Electron.Input,
......@@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15521569 wc.off("did-fail-load", failed as never);
15531570 wc.off("before-input-event", beforeInput);
15541571 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
1572 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
15551573 }).pipe(Effect.ignore),
15561574 );
15571575 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
......@@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15651583 wc.on("did-stop-loading", sync);
15661584 wc.on("did-fail-load", failed as never);
15671585 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1586 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
15681587 wc.setWindowOpenHandler(({ url }) => {
15691588 runFork(
15701589 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
apps/desktop/src/preview/PickPreload.ts+35
......@@ -22,6 +22,7 @@ import {
2222 CANCEL_PICK_CHANNEL,
2323 ELEMENT_PICKED_CHANNEL,
2424 HUMAN_INPUT_CHANNEL,
25 MOUSE_NAVIGATE_CHANNEL,
2526 START_PICK_CHANNEL,
2627} from "./GuestProtocol.ts";
2728const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
......@@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => {
102103window.addEventListener("pointerdown", reportHumanPointerInput, true);
103104window.addEventListener("keydown", reportHumanKeyInput, true);
104105
106// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward.
107const MOUSE_BUTTON_BACK = 3;
108const MOUSE_BUTTON_FORWARD = 4;
109
110const navigationDirectionForButton = (button: number): "back" | "forward" | null => {
111 if (button === MOUSE_BUTTON_BACK) return "back";
112 if (button === MOUSE_BUTTON_FORWARD) return "forward";
113 return null;
114};
115
116// Chromium routes thumb-button history navigation to the *focused* WebContents,
117// so hovering this guest without focusing it sends the host app's router back
118// instead of the preview. Suppress Chromium's default here and drive this tab's
119// history explicitly so the buttons always navigate the browser the pointer is
120// over — never the host app.
121const suppressNavigationButton = (event: MouseEvent): void => {
122 if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return;
123 event.preventDefault();
124 event.stopImmediatePropagation();
125};
126
127const requestNavigationForButton = (event: MouseEvent): void => {
128 if (!event.isTrusted) return;
129 const direction = navigationDirectionForButton(event.button);
130 if (direction === null) return;
131 event.preventDefault();
132 event.stopImmediatePropagation();
133 ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction });
134};
135
136window.addEventListener("mousedown", suppressNavigationButton, true);
137window.addEventListener("mouseup", requestNavigationForButton, true);
138window.addEventListener("auxclick", suppressNavigationButton, true);
139
105140const nextId = (prefix: string): string => {
106141 idSequence += 1;
107142 return `${prefix}_${idSequence.toString(36)}`;