feat(desktop): right-click the shell chrome for window-level actions

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.
This commit is contained in:
Brooklyn Nicholson 2026-08-12 22:41:25 -05:00 committed by brooklyn!
parent 071d27d1c3
commit 4d9202b963
2 changed files with 138 additions and 41 deletions

View File

@ -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}
>
<ContribWiring>
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* Title bar: fixed chrome outside the grid, composable via slots.
<ShellContextMenu>
<div
className="flex h-screen min-h-0 w-screen flex-col bg-(--ui-bg-chrome) text-(--ui-text-primary)"
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* 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. */}
<div className="relative flex h-[34px] shrink-0 items-center bg-(--ui-sidebar-surface-background) text-xs">
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
<div className="relative flex h-[34px] shrink-0 items-center bg-(--ui-sidebar-surface-background) text-xs">
{/* 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). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<TitlebarSlot
area="titleBar.left"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 24px) + 1rem))'
}}
/>
<TitlebarSlot
area="titleBar.center"
className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"
/>
<TitlebarSlot
area="titleBar.right"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * var(--titlebar-control-size, 24px) + 0.5rem))'
}}
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<TitlebarSlot
area="titleBar.left"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 24px) + 1rem))'
}}
/>
<TitlebarSlot
area="titleBar.center"
className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"
/>
<TitlebarSlot
area="titleBar.right"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * var(--titlebar-control-size, 24px) + 0.5rem))'
}}
/>
</div>
<LayoutTreeRoot />
<LayoutTreeRoot />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
{/* The REAL statusbar (model pill, command center, agents, …) with
{/* The REAL statusbar (model pill, command center, agents, …) with
statusBar.left/right contributions merged in. Unmounted — not
just hidden — while toggled off, so its 15s status poll and the
per-turn readouts stop with it. */}
{statusbarVisible && <WiredPane part="statusbar" />}
</div>
{statusbarVisible && <WiredPane part="statusbar" />}
</div>
</ShellContextMenu>
</ContribWiring>
</SidebarProvider>
)

View File

@ -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
})}
<kit.Separator />
{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)
})}
<kit.Separator />
{renderActionItem(kit, {
icon: 'cloud-download',
label: t.commandCenter.updateHermes,
onSelect: requestActiveUpdate
})}
</>
)
return (
<ActionsContextMenu contentClassName="w-44" items={items}>
<div className="contents" data-shell-context-menu="">
{/* 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. */}
<div className="contents" onContextMenu={guard}>
{children}
</div>
</div>
</ActionsContextMenu>
)
}
/** 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<HTMLDivElement>) {
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()
}
}