fix(desktop): use Win+Alt+O and plain tray options on Windows

This commit is contained in:
David Hill
2026-09-30 13:42:20 -06:00
parent 01f49ef1cf
commit 12ee0f165b
6 changed files with 61 additions and 15 deletions
+6 -3
View File
@@ -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.
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
@@ -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, {
+12 -4
View File
@@ -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[]
@@ -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<string, () => 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"])
})
@@ -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
},
}
+2 -2
View File
@@ -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
}