diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 6903e4d0c73..b846b2ed92d 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -56,10 +56,13 @@ highlight and click target, with compact system spacing and default styling. Tra positions the visible favicon alongside the title without changing its size, horizontal padding, or shared hover target. Platforms without native secondary labels retain separate title/detail rows and an invisible alignment spacer. Menu icons have an extra four-point trailing gutter. -Footer shortcuts use the operating system's separate, subdued shortcut column: +On macOS and Linux, footer shortcuts use the operating system's separate, subdued shortcut column: **Cmd/Ctrl+N** for New Agent, **Cmd/Ctrl+,** for Settings, and **Cmd/Ctrl+Q** for Quit. Docs has no shortcut. +They work while the tray menu is open. Windows popup menus do not respond to these shortcuts, so on Windows the footer +items are plain options without shortcut labels. -**Cmd+Option+O** opens the tray globally on macOS (**Ctrl+Alt+O** on Windows/Linux), even while another app is focused. +**Cmd+Option+O** opens the tray globally on macOS (**Ctrl+Alt+O** on Linux, **Win+Alt+O** on Windows), even while another +app is focused. Windows avoids Ctrl+Alt because many keyboard layouts use it as AltGr to type characters. It is registered when the tray icon appears and released when the tray is disabled or the app quits. If another application already owns the combination, the tray remains usable by mouse and a shortcut-registration warning is written to the log. @@ -175,4 +178,4 @@ written to `dist/bench-startup`. A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a -closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub. \ No newline at end of file +closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub. diff --git a/packages/desktop/src/main/native/tray-menu.test.ts b/packages/desktop/src/main/native/tray-menu.test.ts index e026c83b0e9..735d84f4f84 100644 --- a/packages/desktop/src/main/native/tray-menu.test.ts +++ b/packages/desktop/src/main/native/tray-menu.test.ts @@ -164,7 +164,7 @@ describe("tray menu", () => { test("uses the native shortcut column instead of adding shortcuts to action labels", () => { const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } - const menu = trayMenu(actions, empty) + const menu = trayMenu(actions, empty, undefined, true, "darwin") expect( menu .filter((item) => item.click) @@ -177,6 +177,14 @@ describe("tray menu", () => { ]) }) + test("shows plain options without shortcuts on Windows", () => { + const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } + const menu = trayMenu(actions, empty, undefined, false, "win32") + const options = menu.filter((item) => item.click) + expect(options.map((item) => item.label)).toEqual(["New Agent…", "Settings", "Docs", "Quit"]) + expect(options.some((item) => "accelerator" in item)).toBe(false) + }) + test("does not claim stale sessions are live after a connection failure", () => { const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} } const menu = trayMenu(actions, { diff --git a/packages/desktop/src/main/native/tray-menu.ts b/packages/desktop/src/main/native/tray-menu.ts index e636fda43f5..3682838d16b 100644 --- a/packages/desktop/src/main/native/tray-menu.ts +++ b/packages/desktop/src/main/native/tray-menu.ts @@ -18,7 +18,15 @@ export type TrayActions = { session: (id: string) => void } -export function trayMenu(actions: TrayActions, snapshot: TraySessions, icons?: TrayMenuIcons, sublabels = true) { +export function trayMenu( + actions: TrayActions, + snapshot: TraySessions, + icons?: TrayMenuIcons, + sublabels = true, + platform: NodeJS.Platform = process.platform, +) { + // Windows popup menus do not respond to accelerators, so showing them would advertise shortcuts that do nothing. + const accelerator = (key: string) => (platform === "win32" ? {} : { accelerator: key }) const items = snapshot.sessions.flatMap((session, index) => { const group = traySessionGroup(session.status) const previous = snapshot.sessions[index - 1] @@ -85,13 +93,13 @@ export function trayMenu(actions: TrayActions, snapshot: TraySessions, icons?: T { label: nativeT("desktop.tray.newAgent"), icon: icons?.action("newAgent"), - accelerator: "CommandOrControl+N", + ...accelerator("CommandOrControl+N"), click: () => actions.trigger("tab.new"), }, { label: nativeT("desktop.menu.settings"), icon: icons?.action("settings"), - accelerator: "CommandOrControl+,", + ...accelerator("CommandOrControl+,"), click: () => actions.trigger("settings.open"), }, { label: nativeT("desktop.tray.docs"), icon: icons?.action("docs"), click: actions.docs }, @@ -99,7 +107,7 @@ export function trayMenu(actions: TrayActions, snapshot: TraySessions, icons?: T { label: nativeT("desktop.tray.quit"), icon: icons?.action("quit"), - accelerator: "CommandOrControl+Q", + ...accelerator("CommandOrControl+Q"), click: actions.quit, }, ] satisfies MenuItemConstructorOptions[] diff --git a/packages/desktop/src/main/native/tray-shortcut.test.ts b/packages/desktop/src/main/native/tray-shortcut.test.ts index dcf742d0d46..fcb463e4cc5 100644 --- a/packages/desktop/src/main/native/tray-shortcut.test.ts +++ b/packages/desktop/src/main/native/tray-shortcut.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut" +import { registerTrayShortcut, trayShortcut } from "./tray-shortcut" test("registers Command/Control+Alt+O and invokes the tray opener", () => { const callbacks = new Map void>() @@ -19,8 +19,10 @@ test("registers Command/Control+Alt+O and invokes the tray opener", () => { () => { opened.push("tray") }, + "darwin", ) - expect(TRAY_SHORTCUT).toBe("CommandOrControl+Alt+O") + const TRAY_SHORTCUT = "CommandOrControl+Alt+O" + expect(shortcut.key).toBe(TRAY_SHORTCUT) expect(shortcut.registered).toBe(true) callbacks.get(TRAY_SHORTCUT)?.() expect(opened).toEqual(["tray"]) @@ -46,3 +48,22 @@ test("does not unregister a shortcut it could not acquire", () => { shortcut.dispose() expect(released).toEqual([]) }) + +test("uses Win+Alt+O on Windows so AltGr characters remain typeable", () => { + expect(trayShortcut("win32")).toBe("Super+Alt+O") + expect(trayShortcut("darwin")).toBe("CommandOrControl+Alt+O") + expect(trayShortcut("linux")).toBe("CommandOrControl+Alt+O") + const registered: string[] = [] + registerTrayShortcut( + { + register: (key) => { + registered.push(key) + return true + }, + unregister: () => {}, + }, + () => {}, + "win32", + ) + expect(registered).toEqual(["Super+Alt+O"]) +}) diff --git a/packages/desktop/src/main/native/tray-shortcut.ts b/packages/desktop/src/main/native/tray-shortcut.ts index 65fb2eac48d..aaad2d58014 100644 --- a/packages/desktop/src/main/native/tray-shortcut.ts +++ b/packages/desktop/src/main/native/tray-shortcut.ts @@ -1,17 +1,23 @@ -export const TRAY_SHORTCUT = "CommandOrControl+Alt+O" +// Ctrl+Alt is AltGr on many Windows keyboard layouts, so a Ctrl+Alt shortcut would block typed characters. +export function trayShortcut(platform: NodeJS.Platform = process.platform) { + return platform === "win32" ? "Super+Alt+O" : "CommandOrControl+Alt+O" +} export function registerTrayShortcut( registry: { register: (key: string, callback: () => void) => boolean; unregister: (key: string) => void }, open: () => void, + platform: NodeJS.Platform = process.platform, ) { - const state = { registered: registry.register(TRAY_SHORTCUT, open) } + const key = trayShortcut(platform) + const state = { registered: registry.register(key, open) } return { + key, get registered() { return state.registered }, dispose() { if (!state.registered) return - registry.unregister(TRAY_SHORTCUT) + registry.unregister(key) state.registered = false }, } diff --git a/packages/desktop/src/main/native/tray.ts b/packages/desktop/src/main/native/tray.ts index b3450b13173..f47681433ef 100644 --- a/packages/desktop/src/main/native/tray.ts +++ b/packages/desktop/src/main/native/tray.ts @@ -9,7 +9,7 @@ import { traySessionNotification, type TraySessions } from "./tray-sessions" import type { TrayIcons } from "./tray-icons" import { trayTabs } from "./tray-tabs" import { trayStartupPixels } from "./tray-icon-art" -import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut" +import { registerTrayShortcut } from "./tray-shortcut" let tray: Tray | undefined let actions: TrayActions | undefined @@ -132,7 +132,7 @@ function createTray(image: NativeImage) { if (!menuOpen) item.popUpContextMenu() }) if (!shortcut.registered) - log.warn("Unable to register tray shortcut; it may be in use by another application", { shortcut: TRAY_SHORTCUT }) + log.warn("Unable to register tray shortcut; it may be in use by another application", { shortcut: shortcut.key }) return item }