diff --git a/apps/desktop/src/app/context-menu/app-context-menu.test.tsx b/apps/desktop/src/app/context-menu/app-context-menu.test.tsx index ea406caa10..08494f1463 100644 --- a/apps/desktop/src/app/context-menu/app-context-menu.test.tsx +++ b/apps/desktop/src/app/context-menu/app-context-menu.test.tsx @@ -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( + '' + ) + + 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( + + + + + + ) + + const footer = screen.getByText('bar') + + expect(footer.getAttribute('data-slot')).toBe('statusbar') + expect(footer.hasAttribute(HERMES_CONTEXT_MENU_TRIGGER_ATTR)).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/context-menu/app-context-menu.tsx b/apps/desktop/src/app/context-menu/app-context-menu.tsx index 96e478bd06..53966819b1 100644 --- a/apps/desktop/src/app/context-menu/app-context-menu.tsx +++ b/apps/desktop/src/app/context-menu/app-context-menu.tsx @@ -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 } diff --git a/apps/desktop/src/components/ui/context-menu.tsx b/apps/desktop/src/components/ui/context-menu.tsx index 286418baf9..34bfb3728d 100644 --- a/apps/desktop/src/components/ui/context-menu.tsx +++ b/apps/desktop/src/components/ui/context-menu.tsx @@ -12,8 +12,19 @@ function ContextMenuPortal({ ...props }: React.ComponentProps } +/** 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) { - return + return ( + + ) } function ContextMenuGroup({ ...props }: React.ComponentProps) {