From c41e543389198b9c30513621987543b07ac9daf3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:32:29 -0700 Subject: [PATCH 1/4] fix(sidebar): keep the right-click context menu open over the collapsed chat flyout --- .../context-menu/context-menu.test.tsx | 122 ++++++++++++++++++ .../components/context-menu/context-menu.tsx | 15 +++ 2 files changed, 137 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx new file mode 100644 index 00000000000..b5b2346e57b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -0,0 +1,122 @@ +/** + * @vitest-environment jsdom + * + * The sidebar context menu is opened by right-clicking a row, including rows that live + * inside another Radix menu — the collapsed sidebar's chat flyout. Radix menu rows focus + * themselves on `pointermove` and a menu refocuses its own content when the pointer leaves + * a row, so the first mouse movement after the right-click moves focus into the flyout. + * A non-modal Radix menu dismisses on focus-outside, which closed this menu before the + * cursor could reach it. These tests pin which dismissal paths survive: focus landing in a + * surrounding menu keeps it open, focus landing anywhere else and a press outside close it. + */ +import { act } from 'react' +import { sleep } from '@sim/utils/helpers' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ContextMenu } from './context-menu' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let plainButton: HTMLButtonElement | null = null +let surroundingMenuItem: HTMLDivElement | null = null + +/** + * Stands in for the collapsed sidebar's chat flyout: a Radix menu whose rows this menu + * is drawn on top of, and which steals focus back as soon as the pointer moves. + */ +function mountSurroundingMenu() { + const menu = document.createElement('div') + menu.setAttribute('role', 'menu') + const item = document.createElement('div') + item.setAttribute('role', 'menuitem') + item.tabIndex = -1 + menu.appendChild(item) + document.body.appendChild(menu) + surroundingMenuItem = item +} + +function renderMenu(onClose: () => void) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + mountSurroundingMenu() + plainButton = document.createElement('button') + document.body.appendChild(plainButton) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render( + {}} + showRename={false} + showDuplicate={false} + /> + ) + ) +} + +function contextMenuIsOpen() { + return Array.from(document.querySelectorAll('[role="menuitem"]')).some( + (item) => item.textContent === 'Delete' + ) +} + +function focusOutside(element: HTMLElement | null) { + act(() => { + element?.focus() + element?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + plainButton?.remove() + surroundingMenuItem?.closest('[role="menu"]')?.remove() + root = null + container = null + plainButton = null + surroundingMenuItem = null +}) + +describe('sidebar context menu dismissal', () => { + it('stays open when a surrounding menu takes focus back', () => { + const onClose = vi.fn() + renderMenu(onClose) + expect(contextMenuIsOpen()).toBe(true) + + focusOutside(surroundingMenuItem) + + expect(onClose).not.toHaveBeenCalled() + expect(contextMenuIsOpen()).toBe(true) + }) + + it('closes when focus leaves for an element outside any menu', () => { + const onClose = vi.fn() + renderMenu(onClose) + + focusOutside(plainButton) + + expect(onClose).toHaveBeenCalled() + }) + + it('closes on a pointer press outside it', async () => { + const onClose = vi.fn() + renderMenu(onClose) + + await act(async () => { + await sleep(1) + }) + + await act(async () => { + plainButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + plainButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await sleep(1) + }) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index f381806cea1..8be1feef7f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -103,6 +103,15 @@ interface ContextMenuProps { /** * Context menu component for workflow, folder, and workspace items. * Uses DropdownMenu for accessible, hover-expandable submenus. + * + * A non-modal Radix menu dismisses itself whenever focus lands outside it, and this + * menu is routinely opened on top of another Radix menu — the collapsed sidebar's + * chat flyout. Radix menu rows call `focus()` on `pointermove` and a menu refocuses + * its own content when the pointer leaves a row, so the first mouse movement after a + * right-click inside the flyout pulled focus back into the flyout and closed this + * menu before the cursor could reach it. `onFocusOutside` therefore ignores focus + * that lands in a surrounding menu; focus leaving to anything else (tabbing away) + * still dismisses, as do pointer-down outside, Escape, and selecting an item. */ export function ContextMenu({ isOpen, @@ -200,6 +209,12 @@ export function ContextMenu({ side='bottom' sideOffset={4} className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]' + onFocusOutside={(e) => { + const target = e.target + if (target instanceof Element && target.closest('[role="menu"]')) { + e.preventDefault() + } + }} onCloseAutoFocus={(e) => { e.preventDefault() const shouldFocusRenameInput = justSelectedRenameRef.current From 11473a05efdc5fc5a1b7e61ab81c1cd1a0b61706 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:36:16 -0700 Subject: [PATCH 2/4] fix(sidebar): use an absolute import in the context menu test --- .../workflow-list/components/context-menu/context-menu.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx index b5b2346e57b..c80396f77b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -13,7 +13,7 @@ import { act } from 'react' import { sleep } from '@sim/utils/helpers' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import { ContextMenu } from './context-menu' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' let root: Root | null = null let container: HTMLDivElement | null = null From 83c641df859be04b3ae0ea1947e35a0061d6e45f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:40:20 -0700 Subject: [PATCH 3/4] test(sidebar): cover item selection after a surrounding menu takes focus --- .../context-menu/context-menu.test.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx index c80396f77b0..47bea5f08c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -35,7 +35,7 @@ function mountSurroundingMenu() { surroundingMenuItem = item } -function renderMenu(onClose: () => void) { +function renderMenu(onClose: () => void, onDelete: () => void = () => {}) { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true mountSurroundingMenu() plainButton = document.createElement('button') @@ -50,7 +50,7 @@ function renderMenu(onClose: () => void) { position={{ x: 10, y: 10 }} menuRef={{ current: null }} onClose={onClose} - onDelete={() => {}} + onDelete={onDelete} showRename={false} showDuplicate={false} /> @@ -94,6 +94,23 @@ describe('sidebar context menu dismissal', () => { expect(contextMenuIsOpen()).toBe(true) }) + it('still runs an item action after a surrounding menu took focus', () => { + const onClose = vi.fn() + const onDelete = vi.fn() + renderMenu(onClose, onDelete) + + focusOutside(surroundingMenuItem) + act(() => { + const deleteItem = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'Delete' + ) + ;(deleteItem as HTMLElement | undefined)?.click() + }) + + expect(onDelete).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + it('closes when focus leaves for an element outside any menu', () => { const onClose = vi.fn() renderMenu(onClose) From 0494383944025d226403d6aecbb927b3bb9aed2a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:50:50 -0700 Subject: [PATCH 4/4] fix(sidebar): keep the collapsed workflow actions menu open on right-click --- .../collapsed-sidebar-menu.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 814535edf1b..9935ab0c8eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -166,6 +166,22 @@ interface CollapsedWorkflowFlyoutItemProps { canRename?: boolean } +/** + * Suppresses the Radix menu row's own pointer handlers, which focus the row on + * `pointermove` and hand focus back to the flyout content on `pointerleave`. + * A submenu closes on any focus that is not its trigger, so while this row's + * actions submenu is open those two handlers would close it the instant the + * cursor moved — the path a right-click takes, since it opens the submenu with + * the cursor still over the row rather than over the trigger. Radix composes + * consumer handlers ahead of its own and skips its own once the event is + * defaulted, so preventing default here holds focus still until the cursor + * reaches the submenu. Only applied to the row whose submenu is open: moving on + * to any other row still steals focus and closes it, as it should. + */ +const holdRowFocus = (e: React.PointerEvent) => { + if (e.pointerType === 'mouse') e.preventDefault() +} + const EDIT_ROW_CLASS = cn( chipVariants({ active: true, fullWidth: true }), 'min-w-0 cursor-default select-none text-small' @@ -340,6 +356,8 @@ export function CollapsedWorkflowFlyoutItem({