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