From 11ab902002be4a5277f31c182a917adb572ff675 Mon Sep 17 00:00:00 2001 From: Arnab Chakraborty Date: Sat, 19 Sep 2026 15:03:04 +0530 Subject: [PATCH 1/2] fix(desktop): raise the window when a thread notification is clicked on Linux --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 ++ apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 21 +++++++++++++++++++ apps/desktop/src/ipc/methods/window.ts | 18 ++++++++++++++++ apps/desktop/src/preload.ts | 1 + .../ThreadNotificationCoordinator.test.tsx | 19 +++++++++++++++++ .../ThreadNotificationCoordinator.tsx | 1 + packages/contracts/src/ipc.ts | 6 ++++++ 8 files changed, 69 insertions(+) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c97c602552f4..25392b9d4b63 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -47,6 +47,7 @@ import { checkSystemPermission, pasteAsText, probeRemoteEditors, + revealWindow, pickFolder, pickProjectFavicon, pickThemeFiles, @@ -134,6 +135,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); + yield* ipc.handle(revealWindow); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..c7e1638a135a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,5 +1,6 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; +export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 764056742372..9781206b946e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -31,6 +31,7 @@ import { pasteAsText, pickProjectFavicon, probeRemoteEditors, + revealWindow, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -214,6 +215,26 @@ describe("pasteAsText", () => { ); }); +describe("revealWindow", () => { + it.effect("reveals the main window only when it asks", () => { + const window = { webContents: { id: 42 } } as unknown as Electron.BrowserWindow; + const reveal = vi.fn(() => Effect.void); + return Effect.gen(function* () { + yield* revealWindow.handler(undefined, { sender: { id: 99 } }); + assert.equal(reveal.mock.calls.length, 0); + yield* revealWindow.handler(undefined, { sender: { id: 42 } }); + assert.deepEqual(reveal.mock.calls, [[window]]); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + reveal, + }), + ), + ); + }); +}); + describe("pickProjectFavicon", () => { const pickerLayer = (pickFiles: () => Effect.Effect>, settings?: DesktopSettings) => Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1..2faf3d604670 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -373,6 +373,24 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); +export const revealWindow = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REVEAL_WINDOW_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.revealWindow")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.webContents.id !== event.sender.id + ) { + return; + } + yield* electronWindow.reveal(window.value); + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 885b31a64d6b..a29a55a12755 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -78,6 +78,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + revealWindow: () => ipcRenderer.invoke(IpcChannels.REVEAL_WINDOW_CHANNEL), getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..d3c75b5ca01c 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -251,4 +251,23 @@ describe("thread notifications", () => { silent: true, }); }); + + it("reveals the desktop window and opens the thread when a system alert is clicked", async () => { + const revealWindow = vi.fn(() => Promise.resolve()); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { focus: vi.fn(), desktopBridge: { revealWindow } }), + ); + state.mode = "notifications"; + state.focused = false; + await render(); + await complete(); + const notification: EventTarget = state.notification.mock.results[0]?.value; + notification.dispatchEvent(new Event("click")); + expect(revealWindow).toHaveBeenCalledTimes(1); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "env-1", threadId: "thread-1" }, + }); + }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 5feda71ea8f3..3f1fde40a40f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -185,6 +185,7 @@ function EnvironmentNotifications({ notification.addEventListener("click", () => { notification.close(); window.focus(); + void window.desktopBridge?.revealWindow?.().catch(() => undefined); void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7e738b97786b..3c9fbe70eba6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1101,6 +1101,12 @@ export interface DesktopBridge { getClientPlatform?: () => string; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear?: (listener: () => void) => () => void; + /** + * Restores and focuses the calling window from the main process. Renderer + * `window.focus()` cannot raise an Electron window, and on Wayland only the + * main process can spend a notification click's activation token. + */ + revealWindow?: () => Promise; /** * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: * the packaged app ships only the `en-US` Chromium locale pak, so From 34421d278ecb8e368ec4f29980c735897755280f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:18:47 +0000 Subject: [PATCH 2/2] fix(desktop): align notification reveal with current tests --- apps/desktop/src/ipc/channels.ts | 2 +- apps/desktop/src/ipc/methods/window.test.ts | 2 +- .../ThreadNotificationCoordinator.browser.test.tsx | 5 +---- packages/contracts/src/ipc.ts | 5 ++--- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 772beaad32c1..5843f798ab21 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; -export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; @@ -10,6 +9,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; +export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index d092135dde6d..97f5404e4522 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -270,7 +270,7 @@ describe("revealWindow", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeed(Option.some(window)), + main: Effect.succeedSome(window), reveal, }), ), diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 27f2d1934cca..a78049369139 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -362,10 +362,7 @@ describe("thread notifications", () => { it("reveals the desktop window and opens the thread when a system alert is clicked", async () => { const revealWindow = vi.fn(() => Promise.resolve()); - vi.stubGlobal( - "window", - Object.assign(new EventTarget(), { focus: vi.fn(), desktopBridge: { revealWindow } }), - ); + vi.stubGlobal("desktopBridge", { revealWindow }); state.mode = "notifications"; state.focused = false; await render(); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6225fb26fc48..d305682964e1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1033,9 +1033,8 @@ export interface DesktopBridge { onNotificationBadgeClear?: (listener: () => void) => () => void; onTrackpadScrollEnd?: (listener: () => void) => () => void; /** - * Restores and focuses the calling window from the main process. Renderer - * `window.focus()` cannot raise an Electron window, and on Wayland only the - * main process can spend a notification click's activation token. + * Renderer `window.focus()` cannot raise an Electron window, and on Wayland + * only the main process can spend a notification click's activation token. */ revealWindow?: () => Promise; /**