From b040f6cc63d1c629e5d99d66a3447b007a338866 Mon Sep 17 00:00:00 2001 From: Hp-Omen Date: Sun, 1 Nov 2026 02:28:45 +0100 Subject: [PATCH 1/4] feat(desktop): draw context menus in the app on Windows and Linux Native menus ignore the app theme. On Windows and Linux, feature menus now use the in-app menu the web client already draws, and the window's own spelling and Cut/Copy/Paste menu is drawn by the page while the main process still decides its items and runs the one picked. macOS keeps native menus. In-app menus show shortcut hints and open on the other side of the pointer when they would run off the window. --- apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/preload.ts | 13 ++ apps/desktop/src/window/DesktopWindow.ts | 93 +++++++------ .../src/window/WindowContextMenu.test.ts | 78 +++++++++++ apps/desktop/src/window/WindowContextMenu.ts | 131 ++++++++++++++++++ apps/web/src/appInit.tsx | 2 + apps/web/src/components/chat/ChatHeader.tsx | 4 +- apps/web/src/contextMenuFallback.test.ts | 12 ++ apps/web/src/contextMenuFallback.ts | 35 ++++- apps/web/src/lib/desktopWindowContextMenu.ts | 14 ++ apps/web/src/localApi.test.ts | 22 +++ apps/web/src/localApi.ts | 9 +- packages/contracts/src/ipc.ts | 13 ++ 13 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/src/window/WindowContextMenu.test.ts create mode 100644 apps/desktop/src/window/WindowContextMenu.ts create mode 100644 apps/web/src/lib/desktopWindowContextMenu.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 538dd06b0b42a..90577f3f0b12b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ 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"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +export const WINDOW_CONTEXT_MENU_CHANNEL = "desktop:window-context-menu"; +export const WINDOW_CONTEXT_MENU_RESULT_CHANNEL = "desktop:window-context-menu-result"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..d2d86b76e5337 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -197,6 +197,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { items, ...(position === undefined ? {} : { position }), }), + onWindowContextMenu: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + + ipcRenderer.on(IpcChannels.WINDOW_CONTEXT_MENU_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_CONTEXT_MENU_CHANNEL, wrappedListener); + }; + }, + resolveWindowContextMenu: (requestId, itemId) => + ipcRenderer.send(IpcChannels.WINDOW_CONTEXT_MENU_RESULT_CHANNEL, { requestId, itemId }), receiveProviderAuthCallback: (url: string) => ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url), cancelProviderAuthCallback: (url: string) => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..1100c4f2af574 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -25,6 +25,8 @@ import { SNAP_SHOT_EVENT_CHANNEL, TRACKPAD_SCROLL_BEGIN_CHANNEL, TRACKPAD_SCROLL_END_CHANNEL, + WINDOW_CONTEXT_MENU_CHANNEL, + WINDOW_CONTEXT_MENU_RESULT_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; @@ -36,6 +38,12 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; +import { + buildWindowContextMenu, + makeInPageContextMenus, + toNativeContextMenuTemplate, + type WindowContextMenuAction, +} from "./WindowContextMenu.ts"; const TITLEBAR_HEIGHT = 40; // Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. @@ -531,66 +539,61 @@ export const make = Effect.gen(function* () { ): void => { if (contextMenuContents.has(contents)) return; contextMenuContents.add(contents); + // Native menus ignore the app theme. macOS keeps them; elsewhere the app's own page draws + // the menu. Guests and popups have no app page to draw in, so they stay native. + const inPageMenus = + environment.platform !== "darwin" && contents === ownerWindow.webContents + ? makeInPageContextMenus((request) => { + if (!contents.isDestroyed()) contents.send(WINDOW_CONTEXT_MENU_CHANNEL, request); + }) + : null; + if (inPageMenus) { + contents.ipc.on(WINDOW_CONTEXT_MENU_RESULT_CHANNEL, (_event, result: unknown) => + inPageMenus.resolve(result), + ); + } contents.on("context-menu", (event, params) => { event.preventDefault(); if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; // Right-clicking a guest may leave native editing roles targeting the host renderer. contents.focus(); - const menuTemplate: Electron.MenuItemConstructorOptions[] = []; - - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuTemplate.push({ - label: suggestion, - click: () => { - if (!contents.isDestroyed()) contents.replaceMisspelling(suggestion); - }, - }); - } - if (params.dictionarySuggestions.length === 0) { - menuTemplate.push({ label: "No suggestions", enabled: false }); - } - menuTemplate.push({ type: "separator" }); - } - - if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { - menuTemplate.push( - { - label: "Copy Link", - click: () => { - void runPromise(electronShell.copyText(params.linkURL)); - }, - }, - { type: "separator" }, - ); - } - - if (params.mediaType === "image") { - menuTemplate.push({ - label: "Copy Image", - click: () => { + const entries = buildWindowContextMenu( + params, + Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL)), + ); + const run = (action: WindowContextMenuAction) => { + if (contents.isDestroyed()) return; + switch (action.type) { + case "replace-misspelling": + contents.replaceMisspelling(action.word); + return; + case "copy-link": + void runPromise(electronShell.copyText(params.linkURL)); + return; + case "copy-image": void runPromise( Effect.tryPromise(() => copyContextMenuImage(contents, params)).pipe( Effect.catch(() => logWindowWarning("failed to copy context-menu image")), ), ); - }, - }); - menuTemplate.push({ type: "separator" }); - } - - menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); + return; + case "edit": + contents[action.role](); + return; + } + }; + if (inPageMenus) { + // Electron reports window points; the page positions in CSS pixels. + const zoomFactor = contents.getZoomFactor(); + inPageMenus.show(entries, { x: params.x / zoomFactor, y: params.y / zoomFactor }, run); + return; + } void runPromise( electronMenu.popupTemplate({ window: ownerWindow, - template: menuTemplate, + template: toNativeContextMenuTemplate(entries, run), ...(params.frame ? { frame: params.frame } : {}), }), ); diff --git a/apps/desktop/src/window/WindowContextMenu.test.ts b/apps/desktop/src/window/WindowContextMenu.test.ts new file mode 100644 index 0000000000000..cd1ddfba4713d --- /dev/null +++ b/apps/desktop/src/window/WindowContextMenu.test.ts @@ -0,0 +1,78 @@ +import type { DesktopWindowContextMenuRequest } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildWindowContextMenu, + makeInPageContextMenus, + toNativeContextMenuTemplate, + type WindowContextMenuAction, +} from "./WindowContextMenu.ts"; + +const editFlags = { + canUndo: false, + canRedo: false, + canCut: false, + canCopy: true, + canPaste: false, + canDelete: false, + canSelectAll: true, + canEditRichly: false, +}; + +const misspelled = buildWindowContextMenu( + { + misspelledWord: "teh", + dictionarySuggestions: ["the", "ten"], + mediaType: "none", + editFlags, + }, + true, +); + +describe("WindowContextMenu", () => { + it("groups spelling, link, and editing entries with one separator between groups", () => { + expect(misspelled.map((entry) => [entry.label, entry.enabled, entry.separatorBefore])).toEqual([ + ["the", true, false], + ["ten", true, false], + ["Copy Link", true, true], + ["Cut", false, true], + ["Copy", true, false], + ["Paste", false, false], + ["Select all", true, false], + ]); + expect(toNativeContextMenuTemplate(misspelled, () => {}).slice(2, 5)).toMatchObject([ + { type: "separator" }, + { label: "Copy Link" }, + { type: "separator" }, + ]); + }); + + it("runs the picked entry once, and ignores disabled, stale, and unknown answers", () => { + const sent: DesktopWindowContextMenuRequest[] = []; + const ran: WindowContextMenuAction[] = []; + const menus = makeInPageContextMenus((request) => sent.push(request)); + const run = (action: WindowContextMenuAction) => ran.push(action); + + menus.show(misspelled, { x: 10, y: 20 }, run); + menus.show(misspelled, { x: 10, y: 20 }, run); + const [stale, current] = sent; + expect(current?.items[3]).toEqual({ + id: "3", + label: "Cut", + shortcut: "Ctrl+X", + disabled: true, + separatorBefore: true, + }); + + menus.resolve({ requestId: stale?.requestId, itemId: "0" }); + menus.resolve({ requestId: current?.requestId, itemId: "99" }); + expect(ran).toEqual([]); + + menus.show(misspelled, { x: 10, y: 20 }, run); + menus.resolve({ requestId: sent[2]?.requestId, itemId: "3" }); + menus.show(misspelled, { x: 10, y: 20 }, run); + menus.resolve({ requestId: sent[3]?.requestId, itemId: "0" }); + menus.resolve({ requestId: sent[3]?.requestId, itemId: "0" }); + expect(ran).toEqual([{ type: "replace-misspelling", word: "the" }]); + }); +}); diff --git a/apps/desktop/src/window/WindowContextMenu.ts b/apps/desktop/src/window/WindowContextMenu.ts new file mode 100644 index 0000000000000..693b55c71b3bc --- /dev/null +++ b/apps/desktop/src/window/WindowContextMenu.ts @@ -0,0 +1,131 @@ +import type { ContextMenuItem, DesktopWindowContextMenuRequest } from "@t3tools/contracts"; +import type * as Electron from "electron"; + +type EditRole = "cut" | "copy" | "paste" | "selectAll"; + +export type WindowContextMenuAction = + | { readonly type: "replace-misspelling"; readonly word: string } + | { readonly type: "copy-link" } + | { readonly type: "copy-image" } + | { readonly type: "edit"; readonly role: EditRole }; + +export interface WindowContextMenuEntry { + readonly label: string; + readonly enabled: boolean; + readonly separatorBefore: boolean; + readonly action: WindowContextMenuAction | null; +} + +type MenuParams = Pick< + Electron.ContextMenuParams, + "misspelledWord" | "dictionarySuggestions" | "mediaType" | "editFlags" +>; + +const EDIT_ROLES = ["cut", "copy", "paste", "selectAll"] as const; +// Shortcuts are only drawn by the page, which happens on Windows and Linux, so they use Ctrl. +const EDIT_ITEMS = { + cut: { label: "Cut", flag: "canCut", shortcut: "Ctrl+X" }, + copy: { label: "Copy", flag: "canCopy", shortcut: "Ctrl+C" }, + paste: { label: "Paste", flag: "canPaste", shortcut: "Ctrl+V" }, + selectAll: { label: "Select all", flag: "canSelectAll", shortcut: "Ctrl+A" }, +} as const satisfies Record< + EditRole, + { label: string; flag: keyof Electron.EditFlags; shortcut: string } +>; + +/** The menu for a right-click no page handler claimed: spelling, link, image, then editing. */ +export function buildWindowContextMenu( + params: MenuParams, + hasSafeLink: boolean, +): WindowContextMenuEntry[] { + const entries: WindowContextMenuEntry[] = []; + const addSection = (section: ReadonlyArray>) => { + for (const [index, entry] of section.entries()) { + entries.push({ ...entry, separatorBefore: index === 0 && entries.length > 0 }); + } + }; + + if (params.misspelledWord) { + const suggestions = params.dictionarySuggestions.slice(0, 5); + addSection( + suggestions.length > 0 + ? suggestions.map((word) => ({ + label: word, + enabled: true, + action: { type: "replace-misspelling", word } as const, + })) + : [{ label: "No suggestions", enabled: false, action: null }], + ); + } + if (hasSafeLink) { + addSection([{ label: "Copy Link", enabled: true, action: { type: "copy-link" } }]); + } + if (params.mediaType === "image") { + addSection([{ label: "Copy Image", enabled: true, action: { type: "copy-image" } }]); + } + addSection( + EDIT_ROLES.map((role) => ({ + label: EDIT_ITEMS[role].label, + enabled: params.editFlags[EDIT_ITEMS[role].flag], + action: { type: "edit", role } as const, + })), + ); + return entries; +} + +/** Editing entries keep their native roles, so the OS supplies their labels and shortcuts. */ +export function toNativeContextMenuTemplate( + entries: readonly WindowContextMenuEntry[], + run: (action: WindowContextMenuAction) => void, +): Electron.MenuItemConstructorOptions[] { + return entries.flatMap(({ label, enabled, separatorBefore, action }) => { + const item: Electron.MenuItemConstructorOptions = + action?.type === "edit" + ? { role: action.role, enabled } + : { label, enabled, ...(action ? { click: () => run(action) } : {}) }; + return separatorBefore ? [{ type: "separator" as const }, item] : [item]; + }); +} + +/** + * Hands menus to the page to draw and runs the entry it picks. Each new menu replaces the + * previous one, so a late answer for an older menu does nothing. + */ +export function makeInPageContextMenus(send: (request: DesktopWindowContextMenuRequest) => void) { + let lastRequestId = 0; + let pending: { + readonly requestId: number; + readonly entries: readonly WindowContextMenuEntry[]; + readonly run: (action: WindowContextMenuAction) => void; + } | null = null; + + return { + show: ( + entries: readonly WindowContextMenuEntry[], + position: DesktopWindowContextMenuRequest["position"], + run: (action: WindowContextMenuAction) => void, + ) => { + lastRequestId += 1; + pending = { requestId: lastRequestId, entries, run }; + const items: ContextMenuItem[] = entries.map((entry, index) => ({ + id: String(index), + label: entry.label, + ...(entry.action?.type === "edit" + ? { shortcut: EDIT_ITEMS[entry.action.role].shortcut } + : {}), + ...(entry.enabled ? {} : { disabled: true }), + ...(entry.separatorBefore ? { separatorBefore: true } : {}), + })); + send({ requestId: lastRequestId, items, position }); + }, + resolve: (result: unknown) => { + if (typeof result !== "object" || result === null || pending === null) return; + const { requestId, itemId } = result as { requestId?: unknown; itemId?: unknown }; + if (requestId !== pending.requestId) return; + const { entries, run } = pending; + pending = null; + const entry = entries.find((_, index) => String(index) === itemId); + if (entry?.enabled && entry.action) run(entry.action); + }, + }; +} diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 9d77c475155cb..8f6c4d9083c71 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -40,6 +40,7 @@ import { import { syncBrowserChromeTheme } from "./hooks/useTheme"; import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; import { installDesktopPasteAsText } from "./lib/desktopPasteAsText"; +import { installDesktopWindowContextMenu } from "./lib/desktopWindowContextMenu"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -177,6 +178,7 @@ export function startAppServices(): AppRouter { } installDesktopPasteAsText(window.desktopBridge, window); + installDesktopWindowContextMenu(window.desktopBridge); startClientAppearance(); startDocumentTitle(); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 144402d1dfe9f..adb0155e49a00 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -24,7 +24,7 @@ import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; -import { readLocalApi } from "~/localApi"; +import { readLocalApi, usesNativeContextMenus } from "~/localApi"; import { threadEnvironment } from "../../state/threads"; import { canShareWithGuestAtom } from "./guestShareAvailability"; import { useAtomValue } from "@effect/atom-react"; @@ -229,7 +229,7 @@ export const ChatHeader = memo(function ChatHeader({ if (isTrailingDoubleClick(event.detail)) return; const clickedChevron = (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; - if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { + if (event.detail === 0 || clickedChevron || !usesNativeContextMenus()) { openTitleMenuNow(); return; } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 3c7e6b742fe42..af45ad9989046 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -237,4 +237,16 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + + it.each([ + { at: { x: 100, y: 100 }, left: "100px", top: "100px" }, + { at: { x: 1200, y: 760 }, left: "1020px", top: "640px" }, + ])("opens at $at.x,$at.y on the side of the pointer that fits", ({ at, left, top }) => { + void showContextMenuFallback([{ id: "rename", label: "Rename" }], at); + const menu = (document as unknown as FakeDocument) + .querySelectorAll("div") + .find((element) => element.dataset.contextMenu === "true"); + + expect(menu?.style).toMatchObject({ left, top }); + }); }); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a3e760ddc58ab..697c6e63ca21c 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -196,14 +196,30 @@ function createIconElement(name: string, tone: "neutral" | "destructive"): SVGSV return svg; } -function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { +/** Like a native menu, a menu opened at a point moves to the point's other side when it overflows. */ +function flipAtPoint(start: number, size: number, limit: number): number { + return start + size > limit - 4 && start - size >= 4 ? start - size : start; +} + +function clampMenuPosition( + menu: HTMLDivElement, + preferredLeft: number, + preferredTop: number, + atPoint = false, +) { const rect = menu.getBoundingClientRect(); const left = Math.min( - Math.max(4, preferredLeft), + Math.max( + 4, + atPoint ? flipAtPoint(preferredLeft, rect.width, window.innerWidth) : preferredLeft, + ), Math.max(4, window.innerWidth - rect.width - 4), ); const top = Math.min( - Math.max(4, preferredTop), + Math.max( + 4, + atPoint ? flipAtPoint(preferredTop, rect.height, window.innerHeight) : preferredTop, + ), Math.max(4, window.innerHeight - rect.height - 4), ); menu.style.left = `${left}px`; @@ -477,6 +493,17 @@ export function showContextMenuFallback( label.textContent = item.label; button.appendChild(label); + if (item.shortcut && !hasChildren) { + // Matches MenuShortcut in components/ui/menu. + const shortcut = document.createElement("kbd"); + shortcut.className = + "ms-auto ps-4 font-medium font-sans text-secondary-label text-xs tracking-widest"; + shortcut.style.cssText = + "margin-inline-start:auto;padding-inline-start:1rem;font-family:var(--font-sans,system-ui,sans-serif);font-size:0.75rem;font-weight:500;letter-spacing:0.1em;color:var(--contrast-secondary-label);"; + shortcut.textContent = item.shortcut; + button.appendChild(shortcut); + } + if (hasChildren) { button.setAttribute("aria-haspopup", "menu"); button.setAttribute("aria-expanded", "false"); @@ -581,7 +608,7 @@ export function showContextMenuFallback( submenuTriggerStack[level] = parentTrigger; requestAnimationFrame(() => { - clampMenuPosition(menu, preferredLeft, preferredTop); + clampMenuPosition(menu, preferredLeft, preferredTop, level === 0); }); }; diff --git a/apps/web/src/lib/desktopWindowContextMenu.ts b/apps/web/src/lib/desktopWindowContextMenu.ts new file mode 100644 index 0000000000000..b9a2dc8d2683d --- /dev/null +++ b/apps/web/src/lib/desktopWindowContextMenu.ts @@ -0,0 +1,14 @@ +import type { DesktopBridge } from "@t3tools/contracts"; + +import { showContextMenuFallback } from "../contextMenuFallback"; + +/** Draws the desktop shell's own right-click menu (spelling, links, editing) in the page. */ +export function installDesktopWindowContextMenu( + bridge: Pick | undefined, +): (() => void) | undefined { + return bridge?.onWindowContextMenu(({ requestId, items, position }) => { + void showContextMenuFallback(items, position).then((itemId) => + bridge.resolveWindowContextMenu(requestId, itemId), + ); + }); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index b5f9bd18bf98e..f8a71984a7c4a 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -71,4 +71,26 @@ describe("LocalApi", () => { await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false); }); + + it.each([ + { platform: "darwin", native: true }, + { platform: "win32", native: false }, + { platform: "linux", native: false }, + ])("draws $platform desktop context menus in the page unless on macOS", async (row) => { + const showContextMenu = vi.fn(async () => "rename"); + Object.defineProperty(testWindow(), "desktopBridge", { + configurable: true, + value: { getClientPlatform: () => row.platform, showContextMenu }, + }); + showContextMenuFallbackMock.mockResolvedValue("rename"); + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); + + await expect(api.contextMenu.show([{ id: "rename", label: "Rename" }])).resolves.toBe("rename"); + await api.contextMenu.close(); + + expect(showContextMenu).toHaveBeenCalledTimes(row.native ? 1 : 0); + expect(showContextMenuFallbackMock).toHaveBeenCalledTimes(row.native ? 0 : 1); + expect(dismissContextMenuMock).toHaveBeenCalledTimes(row.native ? 0 : 1); + }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index b505a4f416200..86c077f64c2af 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -6,6 +6,11 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP let cachedApi: LocalApi | undefined; +/** Native menus ignore the app theme, so only macOS, where they match the system, keeps them. */ +export function usesNativeContextMenus(): boolean { + return window.desktopBridge?.getClientPlatform() === "darwin"; +} + export function createLocalApi(): LocalApi { return { dialogs: { @@ -44,14 +49,14 @@ export function createLocalApi(): LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise => { - if (window.desktopBridge) { + if (window.desktopBridge && usesNativeContextMenus()) { return window.desktopBridge.showContextMenu(items, position) as Promise; } return showContextMenuFallback(items, position); }, // Unlike native menus, the DOM fallback needs dismissal when underlying state disappears. close: async () => { - if (!window.desktopBridge) { + if (!usesNativeContextMenus()) { dismissContextMenu(); } }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f14452fec5a92..f1b9dc8b7171a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -28,11 +28,20 @@ export interface ContextMenuItem { disabled?: boolean; header?: boolean; icon?: string; + /** Keyboard shortcut hint shown at the end of the row, such as "Ctrl+C". */ + shortcut?: string; separatorBefore?: boolean; checked?: boolean; children?: readonly ContextMenuItem[]; } +/** A right-click the page left unhandled, which the desktop shell asks the page to draw. */ +export interface DesktopWindowContextMenuRequest { + readonly requestId: number; + readonly items: readonly ContextMenuItem[]; + readonly position: { readonly x: number; readonly y: number }; +} + export type QuitShortcutHintEvent = | { readonly state: "down"; readonly mode: Exclude } | { readonly state: "up" }; @@ -44,6 +53,7 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; + readonly shortcut?: string; readonly separatorBefore?: boolean; readonly checked?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; @@ -56,6 +66,7 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), + shortcut: Schema.optionalKey(Schema.String), separatorBefore: Schema.optionalKey(Schema.Boolean), checked: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( @@ -1092,6 +1103,8 @@ export interface DesktopBridge { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; + onWindowContextMenu: (listener: (request: DesktopWindowContextMenuRequest) => void) => () => void; + resolveWindowContextMenu: (requestId: number, itemId: string | null) => void; receiveProviderAuthCallback: (authorizationUrl: string) => Promise; cancelProviderAuthCallback: (authorizationUrl: string) => Promise; openExternal: (url: string) => Promise; From b947e144fe7ebb46ce036a9c40562e9973b1c59b Mon Sep 17 00:00:00 2001 From: Hp-Omen Date: Sun, 1 Nov 2026 02:46:40 +0100 Subject: [PATCH 2/4] fix(desktop): popup windows keep their native context menu --- apps/desktop/src/window/DesktopWindow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1100c4f2af574..8707588e72ec5 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -542,7 +542,7 @@ export const make = Effect.gen(function* () { // Native menus ignore the app theme. macOS keeps them; elsewhere the app's own page draws // the menu. Guests and popups have no app page to draw in, so they stay native. const inPageMenus = - environment.platform !== "darwin" && contents === ownerWindow.webContents + environment.platform !== "darwin" && contents === window.webContents ? makeInPageContextMenus((request) => { if (!contents.isDestroyed()) contents.send(WINDOW_CONTEXT_MENU_CHANNEL, request); }) From d012b19e3e7ddcae375cd48306f6e0a67f2995b5 Mon Sep 17 00:00:00 2001 From: Hp-Omen Date: Sun, 1 Nov 2026 03:11:08 +0100 Subject: [PATCH 3/4] fix(web): a right-click on an open context menu opens no second menu --- apps/web/src/contextMenuFallback.test.ts | 17 +++++++++++++++++ apps/web/src/contextMenuFallback.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index af45ad9989046..c05dfba4365b0 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -174,6 +174,13 @@ class FakeDocument { } } + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); } @@ -238,6 +245,16 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("opens no other menu on a right-click inside the open menu", () => { + void showContextMenuFallback([{ id: "rename", label: "Rename" }]); + const rightClick = new FakeDomEvent("contextmenu", { target: findButton("Rename") }); + + (document as unknown as FakeDocument).dispatchEvent(rightClick); + + expect(rightClick.defaultPrevented).toBe(true); + expect(findButton("Rename")).toBeDefined(); + }); + it.each([ { at: { x: 100, y: 100 }, left: "100px", top: "100px" }, { at: { x: 1200, y: 760 }, left: "1020px", top: "640px" }, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 697c6e63ca21c..631c565a417e2 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -378,9 +378,12 @@ export function showContextMenuFallback( }; const onContextMenu = (event: MouseEvent) => { - if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { + // A right-click on the menu itself opens nothing, as on a native menu. + if (isNodeWithinMenuStack(event.target, menuStack)) { + event.preventDefault(); return; } + if (!canDismissFromPointer) return; event.preventDefault(); cleanup(null); }; From e579a31e9929ccdf9ad0e50b8e419afe7ca5cf8d Mon Sep 17 00:00:00 2001 From: Hp-Omen Date: Sun, 1 Nov 2026 03:40:21 +0100 Subject: [PATCH 4/4] fix(web): an overflowing context submenu stays beside its parent --- apps/web/src/contextMenuFallback.test.ts | 31 ++++++++++++++++++++++++ apps/web/src/contextMenuFallback.ts | 8 +++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index c05dfba4365b0..7d1dc4b712422 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -245,6 +245,37 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("keeps a submenu that overflows to the right beside its parent after the next frame", () => { + const frames: Array<(time: number) => void> = []; + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + frames.push(callback); + return 0; + }); + vi.stubGlobal("window", { + innerWidth: 400, + innerHeight: 800, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const runFrames = () => { + for (const frame of frames.splice(0)) frame(0); + }; + + void showContextMenuFallback( + [{ id: "group", label: "Group with", children: [{ id: "one", label: "Terminal 1" }] }], + { x: 200, y: 0 }, + ); + runFrames(); + const trigger = findButton("Group with"); + trigger!.style.left = "200px"; + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + runFrames(); + + const parent = trigger?.parent?.parent?.getBoundingClientRect(); + const submenu = findButton("Terminal 1")?.parent?.parent?.getBoundingClientRect(); + expect(submenu!.right).toBeLessThanOrEqual(parent!.left); + }); + it("opens no other menu on a right-click inside the open menu", () => { void showContextMenuFallback([{ id: "rename", label: "Rename" }]); const rightClick = new FakeDomEvent("contextmenu", { target: findButton("Rename") }); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 631c565a417e2..12445ec7d3511 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -611,7 +611,13 @@ export function showContextMenuFallback( submenuTriggerStack[level] = parentTrigger; requestAnimationFrame(() => { - clampMenuPosition(menu, preferredLeft, preferredTop, level === 0); + if (level === 0) { + clampMenuPosition(menu, preferredLeft, preferredTop, true); + return; + } + // openSubmenu may already have flipped this menu left of its parent; keep that side. + const current = menu.getBoundingClientRect(); + clampMenuPosition(menu, current.left, current.top); }); };