From 4d9202b9633fa8e5d8e47b8cb9e07ff3cd92636b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 12 Aug 2026 22:41:25 -0500 Subject: [PATCH] feat(desktop): right-click the shell chrome for window-level actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking anywhere the app owns no menu of its own — the titlebar gutter, an empty pane body, the sidebar background — did nothing at all. Electron's native handler bails on non-editable, non-selected content by design, so those surfaces had no menu to fall back to. Wrap the shell in a fallback context menu carrying the verbs that belong to the window rather than to a row: new session, new window, command palette, toggle the status bar, settings, update Hermes. Every row reuses the store action and the copy its Cmd+K twin already uses, so the two can't drift. A guard on an inner element keeps it a fallback: a right-click that lands inside a surface with its own context menu, on an editable, or on a live selection stops propagating before Radix's trigger sees it, leaving that surface's menu — or Electron's native edit menu — in charge. --- apps/desktop/src/app/contrib/controller.tsx | 85 +++++++++-------- .../src/app/shell/shell-context-menu.tsx | 94 +++++++++++++++++++ 2 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/app/shell/shell-context-menu.tsx diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 0210188e23bf9..a70efb6f6549b 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -74,6 +74,7 @@ import { import { HudShell } from '../hud/hud-shell' import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' import { $workspaceIsPage } from '../routes' +import { ShellContextMenu } from '../shell/shell-context-menu' import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { ContribWiring, WiredPane } from './wiring' @@ -718,11 +719,12 @@ export function ContribController() { style={{ '--sidebar-width': '100%' } as CSSProperties} > -
- {/* Title bar: fixed chrome outside the grid, composable via slots. + +
+ {/* Title bar: fixed chrome outside the grid, composable via slots. Layout contract (no contribution can break it): - a full-bar DRAG BASE underneath (pointer-events-none, like AppShell's drag strips) — everywhere without content drags @@ -733,53 +735,54 @@ export function ContribController() { tree-published --workspace-left/right vars (pure CSS, no rect threading), clamped to clear the REAL TitlebarControls clusters (fixed, z-70); center is truly window-centered. */} -
- {/* Drag strips, AppShell-style: cut to AVOID the fixed control +
+ {/* Drag strips, AppShell-style: cut to AVOID the fixed control clusters instead of overlapping them — Electron's no-drag carve-out of fixed/transformed elements is unreliable, so a full-bar drag base kills their clicks. In-flow slot content still carves via its own no-drag wrapper (the same pattern as the app's session-title button). */} - + ) diff --git a/apps/desktop/src/app/shell/shell-context-menu.tsx b/apps/desktop/src/app/shell/shell-context-menu.tsx new file mode 100644 index 0000000000000..4d8bebc24d49d --- /dev/null +++ b/apps/desktop/src/app/shell/shell-context-menu.tsx @@ -0,0 +1,94 @@ +import type * as React from 'react' +import { useNavigate } from 'react-router' + +import { hasTextSelection } from '@/components/assistant-ui/thread/user-message' +import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' +import { useI18n } from '@/i18n' +import { isEditableTarget } from '@/lib/keybinds/combo' +import { openCommandPalette } from '@/store/command-palette' +import { toggleStatusbarVisible } from '@/store/statusbar-prefs' +import { requestActiveUpdate } from '@/store/updates' +import { canOpenNewWindow, openNewWindow } from '@/store/windows' + +import { navigateToWorkspacePage, NEW_CHAT_ROUTE, SETTINGS_ROUTE } from '../routes' + +/** + * The app's fallback context menu: right-clicking chrome that owns no menu of + * its own (the titlebar gutter, a pane's empty body, the sidebar background) + * gets the window-level verbs instead of nothing. + * + * It wraps the whole shell, so the guard below is what keeps it a FALLBACK: a + * right-click that lands inside a surface with its own context menu, on an + * editable, or on a live selection is stopped before Radix's trigger sees it, + * leaving that surface's menu — or Electron's native edit menu — in charge. + * `stopPropagation` (never `preventDefault`) is the mechanism, because + * preventing the default is what would swallow the native menu. + */ +export function ShellContextMenu({ children }: { children: React.ReactNode }) { + const { t } = useI18n() + const navigate = useNavigate() + + const items = (kit: MenuKit) => ( + <> + {renderActionItem(kit, { + icon: 'add', + label: t.commandCenter.nav.newChat.title, + onSelect: () => navigateToWorkspacePage(navigate, NEW_CHAT_ROUTE) + })} + {canOpenNewWindow() && + renderActionItem(kit, { + icon: 'multiple-windows', + label: t.keybinds.actions['session.newWindow'], + onSelect: () => void openNewWindow() + })} + {renderActionItem(kit, { + icon: 'search', + label: t.commandCenter.paletteTitle, + onSelect: openCommandPalette + })} + + {renderActionItem(kit, { + icon: 'layout-statusbar', + label: t.keybinds.actions['view.toggleStatusbar'], + onSelect: toggleStatusbarVisible + })} + {renderActionItem(kit, { + icon: 'settings-gear', + label: t.commandCenter.settings, + onSelect: () => navigateToWorkspacePage(navigate, SETTINGS_ROUTE) + })} + + {renderActionItem(kit, { + icon: 'cloud-download', + label: t.commandCenter.updateHermes, + onSelect: requestActiveUpdate + })} + + ) + + return ( + +
+ {/* The guard sits on an INNER element on purpose: Radix's trigger props + are merged onto the same node as ours (asChild), so a handler there + can't stop the trigger's — but a child's stopPropagation never + reaches it. */} +
+ {children} +
+
+
+ ) +} + +/** Right-clicks that already have an owner keep it: a surface with its own + * context menu, an editable, or a live selection (Electron's native edit menu). + * Never `preventDefault` — that is what would swallow the native menu. */ +function guard(event: React.MouseEvent) { + const target = event.target as HTMLElement | null + const owner = target?.closest('[data-slot="context-menu-trigger"]') + + if ((owner && !owner.hasAttribute('data-shell-context-menu')) || isEditableTarget(target) || hasTextSelection()) { + event.stopPropagation() + } +}