diff --git a/static/app/views/explore/conversations/components/conversationsTable.spec.tsx b/static/app/views/explore/conversations/components/conversationsTable.spec.tsx index 7b4d9ea882bf..430b7f261571 100644 --- a/static/app/views/explore/conversations/components/conversationsTable.spec.tsx +++ b/static/app/views/explore/conversations/components/conversationsTable.spec.tsx @@ -175,6 +175,52 @@ describe('ConversationsTable', () => { expect(router.location.pathname).toContain('conv-1'); }); }); + + it('opens the conversation in a new tab on cmd/ctrl+click', async () => { + mockConversations([{...BASE_CONVERSATION, title: 'Open me'}]); + + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + + const user = userEvent.setup(); + const {router} = renderTable(); + const initialPath = router.location.pathname; + + // Hold both Meta (mac) and Control (other platforms) so the modifier is + // detected regardless of the test platform. A single `user` instance is + // required so the held keys carry into the click event. + await user.keyboard('{Meta>}{Control>}'); + await user.click(await screen.findByText('Open me')); + await user.keyboard('{/Control}{/Meta}'); + + expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('conv-1'), '_blank'); + // Modifier+click must not also navigate the current tab. + expect(router.location.pathname).toBe(initialPath); + + openSpy.mockRestore(); + }); + + it('opens the conversation in a new window on shift+click', async () => { + mockConversations([{...BASE_CONVERSATION, title: 'Open me'}]); + + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + + const user = userEvent.setup(); + const {router} = renderTable(); + const initialPath = router.location.pathname; + + await user.keyboard('{Shift>}'); + await user.click(await screen.findByText('Open me')); + await user.keyboard('{/Shift}'); + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('conv-1'), + '_blank', + 'noopener,noreferrer' + ); + expect(router.location.pathname).toBe(initialPath); + + openSpy.mockRestore(); + }); }); describe('collapseToolsColumnWhenUnused', () => { diff --git a/static/app/views/explore/conversations/components/conversationsTable.tsx b/static/app/views/explore/conversations/components/conversationsTable.tsx index 50d3c8c3798a..56857bbf96b8 100644 --- a/static/app/views/explore/conversations/components/conversationsTable.tsx +++ b/static/app/views/explore/conversations/components/conversationsTable.tsx @@ -25,6 +25,7 @@ import {TimeSince} from 'sentry/components/timeSince'; import {IconFire, IconUser} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed'; import {markdownToPlainText} from 'sentry/utils/marked/marked'; import {ellipsize} from 'sentry/utils/string/ellipsize'; import {isUUID} from 'sentry/utils/string/isUUID'; @@ -187,8 +188,24 @@ export function ConversationsTable() { }; const handleRowClick = useCallback( - (dataRow: Conversation) => { - navigate(getConversationDetailUrl(organization.slug, dataRow, selection.projects)); + (dataRow: Conversation, _key: number, event: React.MouseEvent) => { + const url = getConversationDetailUrl( + organization.slug, + dataRow, + selection.projects + ); + // Mirror native link behavior instead of navigating in place: Cmd/Ctrl+click + // opens a new tab (no features string) and Shift+click opens a new window + // (a features string makes browsers open a window rather than a tab). + if (event.shiftKey) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + if (isCtrlKeyPressed(event)) { + window.open(url, '_blank'); + return; + } + navigate(url); }, [navigate, organization.slug, selection.projects] );