fix(desktop): keep Radix context menus when asChild overwrites data-slot

The app-wide context-menu coordinator recognizes surfaces that own a Radix
menu by `[data-slot="context-menu-trigger"]`. Radix `asChild` merges as
mergeProps(slotProps, childProps), so a child that sets its own `data-slot`
wins and the marker never reaches the DOM. The status bar footer is
`data-slot="statusbar"`, so the coordinator swallowed its right-click and
showed the window-verbs fallback instead — leaving every default-hidden
status bar item, the context meter included, unreachable from the UI.

Stamp a dedicated `data-hermes-context-menu-trigger` after `{...props}` on
ContextMenuTrigger and bail on that marker. Any asChild surface with its own
`data-slot` is covered, not just the status bar.
This commit is contained in:
aydnOktay
2026-08-19 23:30:53 -05:00
committed by brooklyn!
parent b4f978d983
commit 2d6d7c550f
3 changed files with 58 additions and 2 deletions

View File

@@ -3,6 +3,11 @@ import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { registerTerminalContextMenu } from '@/app/right-sidebar/terminal/terminal-context-menu'
import {
ContextMenu,
ContextMenuTrigger,
HERMES_CONTEXT_MENU_TRIGGER_ATTR
} from '@/components/ui/context-menu'
import { formatCombo } from '@/lib/keybinds/combo'
import { $previewTabs, closeRightRail } from '@/store/preview'
import { $connection } from '@/store/session'
@@ -354,6 +359,22 @@ describe('AppContextMenu', () => {
expect($contextMenu.get()).toBeNull()
})
it('leaves asChild radix triggers alone when the child overwrites data-slot', () => {
installBridge()
mountMenu()
// Status bar: ContextMenuTrigger asChild over a footer whose data-slot
// is "statusbar". The coordinator must still recognize the dedicated
// marker — otherwise the window-verbs fallback eats the gesture.
const host = attach(
'<footer data-slot="statusbar" data-hermes-context-menu-trigger=""><span>meter</span></footer>'
)
fireEvent.contextMenu(host.querySelector('span')!)
expect($contextMenu.get()).toBeNull()
expect(screen.queryByText('Settings')).toBeNull()
})
it('shows the terminal menu through a registered handle', async () => {
installBridge()
mountMenu()
@@ -567,3 +588,20 @@ describe('AppContextMenu guest (in-app browser)', () => {
expect(screen.queryByText('Add to dictionary')).toBeNull()
})
})
describe('ContextMenuTrigger asChild', () => {
it('keeps the coordinator marker when the child overwrites data-slot', () => {
render(
<ContextMenu>
<ContextMenuTrigger asChild>
<footer data-slot="statusbar">bar</footer>
</ContextMenuTrigger>
</ContextMenu>
)
const footer = screen.getByText('bar')
expect(footer.getAttribute('data-slot')).toBe('statusbar')
expect(footer.hasAttribute(HERMES_CONTEXT_MENU_TRIGGER_ATTR)).toBe(true)
})
})

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'
import { terminalMenuHandleFor } from '@/app/right-sidebar/terminal/terminal-context-menu'
import { Codicon } from '@/components/ui/codicon'
import { HERMES_CONTEXT_MENU_TRIGGER_ATTR } from '@/components/ui/context-menu'
import { writeClipboardText } from '@/components/ui/copy-button'
import {
DropdownMenu,
@@ -609,7 +610,13 @@ export function AppContextMenu() {
const element = event.target instanceof Element ? event.target : null
// Surfaces with their own Radix context menu keep the whole gesture.
if (element?.closest('[data-slot="context-menu-trigger"]')) {
// Guard the dedicated marker first: Radix `asChild` Slot merges
// `mergeProps(slotProps, childProps)` so the child's `data-slot` wins
// (status bar footer is `data-slot="statusbar"`). The marker is stamped
// after `{...props}` on ContextMenuTrigger and is not overwritten.
if (
element?.closest(`[${HERMES_CONTEXT_MENU_TRIGGER_ATTR}], [data-slot="context-menu-trigger"]`)
) {
return
}

View File

@@ -12,8 +12,19 @@ function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenu
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
/** Coordinator marker that survives Radix `asChild` Slot merges.
* `data-slot` is overwritten when the child sets its own `data-slot`
* (status bar footer); this attribute is not. */
export const HERMES_CONTEXT_MENU_TRIGGER_ATTR = 'data-hermes-context-menu-trigger'
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
{...props}
data-hermes-context-menu-trigger=""
/>
)
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {