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:
parent
071d27d1c3
commit
4d9202b963
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue