Skip to content

Commit 8f2cbe5

Browse files
JonasBaclaudecodex
authored
ref(cmdk) add link detection (#112611)
Add link detection and inject either a IconLink or IconOpen based on the link's destination. <img width="1090" height="908" alt="CleanShot 2026-04-09 at 21 22 58@2x" src="https://github.com/user-attachments/assets/4a4c9a60-aa5f-4023-b4b8-3a3a54df8a50" /> The change also adds tracking for shift key modifier that triggers opening links in new tab --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent 27cdca4 commit 8f2cbe5

File tree

7 files changed

+206
-26
lines changed

7 files changed

+206
-26
lines changed

static/app/components/commandPalette/__stories__/components.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export function CommandPaletteDemo() {
1313
const navigate = useNavigate();
1414

1515
const handleAction = useCallback(
16-
(action: CollectionTreeNode<CMDKActionData>) => {
16+
(
17+
action: CollectionTreeNode<CMDKActionData>,
18+
_options?: {modifierKeys?: {shiftKey: boolean}}
19+
) => {
1720
if ('to' in action) {
1821
navigate(normalizeUrl(action.to));
1922
} else if ('onAction' in action) {

static/app/components/commandPalette/ui/commandPalette.spec.tsx

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,33 @@ import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette
3232
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
3333
import {useNavigate} from 'sentry/utils/useNavigate';
3434

35-
function GlobalActionsComponent({children}: {children?: React.ReactNode}) {
35+
function GlobalActionsComponent({
36+
children,
37+
onAction,
38+
}: {
39+
children?: React.ReactNode;
40+
onAction?: (
41+
action: CollectionTreeNode<CMDKActionData>,
42+
options?: {modifierKeys?: {shiftKey: boolean}}
43+
) => void;
44+
}) {
3645
const navigate = useNavigate();
3746

3847
const handleAction = useCallback(
39-
(action: CollectionTreeNode<CMDKActionData>) => {
40-
if ('to' in action) {
48+
(
49+
action: CollectionTreeNode<CMDKActionData>,
50+
options?: {modifierKeys?: {shiftKey: boolean}}
51+
) => {
52+
if (onAction) {
53+
onAction(action, options);
54+
} else if ('to' in action) {
4155
navigate(action.to);
4256
} else if ('onAction' in action) {
4357
action.onAction();
4458
}
4559
closeModal();
4660
},
47-
[navigate]
61+
[navigate, onAction]
4862
);
4963

5064
return (
@@ -104,6 +118,46 @@ describe('CommandPalette', () => {
104118
expect(closeSpy).toHaveBeenCalledTimes(1);
105119
});
106120

121+
it('shift-enter on an internal link forwards modifier keys and closes modal', async () => {
122+
const closeSpy = jest.spyOn(modalActions, 'closeModal');
123+
const onAction = jest.fn();
124+
125+
render(
126+
<GlobalActionsComponent onAction={onAction}>
127+
<CMDKAction to="/target/" display={{label: 'Go to route'}} />
128+
</GlobalActionsComponent>
129+
);
130+
131+
await screen.findByRole('textbox', {name: 'Search commands'});
132+
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
133+
134+
expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), {
135+
modifierKeys: {shiftKey: true},
136+
});
137+
expect(closeSpy).toHaveBeenCalledTimes(1);
138+
});
139+
140+
it('shows internal and external trailing link indicators for link actions', async () => {
141+
render(
142+
<GlobalActionsComponent>
143+
<Fragment>
144+
<CMDKAction to="/target/" display={{label: 'Internal'}} />
145+
<CMDKAction to="https://docs.sentry.io" display={{label: 'External'}} />
146+
</Fragment>
147+
</GlobalActionsComponent>
148+
);
149+
150+
const internalAction = await screen.findByRole('option', {name: 'Internal'});
151+
const externalAction = await screen.findByRole('option', {name: 'External'});
152+
153+
expect(
154+
internalAction.querySelector('[data-test-id="command-palette-link-indicator"]')
155+
).toHaveAttribute('data-link-type', 'internal');
156+
expect(
157+
externalAction.querySelector('[data-test-id="command-palette-link-indicator"]')
158+
).toHaveAttribute('data-link-type', 'external');
159+
});
160+
107161
it('clicking action with children shows sub-items, backspace returns', async () => {
108162
const closeSpy = jest.spyOn(modalActions, 'closeModal');
109163
render(

static/app/components/commandPalette/ui/commandPalette.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react';
1+
import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react';
22
import {preload} from 'react-dom';
33
import {useTheme} from '@emotion/react';
44
import styled from '@emotion/styled';
@@ -26,10 +26,11 @@ import {
2626
useCommandPaletteDispatch,
2727
useCommandPaletteState,
2828
} from 'sentry/components/commandPalette/ui/commandPaletteStateContext';
29+
import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils';
2930
import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics';
3031
import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
3132
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
32-
import {IconArrow, IconClose, IconSearch} from 'sentry/icons';
33+
import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons';
3334
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
3435
import {t} from 'sentry/locale';
3536
import {fzf} from 'sentry/utils/search/fzf';
@@ -64,7 +65,10 @@ type CMDKFlatItem = CollectionTreeNode<CMDKActionData> & {
6465
};
6566

6667
interface CommandPaletteProps {
67-
onAction: (action: CollectionTreeNode<CMDKActionData>) => void;
68+
onAction: (
69+
action: CollectionTreeNode<CMDKActionData>,
70+
options?: {modifierKeys?: {shiftKey: boolean}}
71+
) => void;
6872
}
6973

7074
export function CommandPalette(props: CommandPaletteProps) {
@@ -200,7 +204,12 @@ export function CommandPalette(props: CommandPaletteProps) {
200204
});
201205

202206
const onActionSelection = useCallback(
203-
(key: string | number | null) => {
207+
(
208+
key: string | number | null,
209+
options?: {
210+
modifierKeys?: {shiftKey: boolean};
211+
}
212+
) => {
204213
const action = actions.find(a => a.key === key);
205214
if (!action) {
206215
return;
@@ -212,7 +221,7 @@ export function CommandPalette(props: CommandPaletteProps) {
212221
analytics.recordGroupAction(action, resultIndex);
213222
if ('onAction' in action) {
214223
// Run the primary callback before drilling into the secondary actions.
215-
// The modal only owns navigation and close behavior for leaf actions.
224+
// Modifier keys are irrelevant here — this is not a link navigation.
216225
action.onAction();
217226
}
218227
dispatch({type: 'push action', key: action.key, label: action.display.label});
@@ -231,12 +240,31 @@ export function CommandPalette(props: CommandPaletteProps) {
231240

232241
analytics.recordAction(action, resultIndex, '');
233242
dispatch({type: 'trigger action'});
234-
props.onAction(action);
243+
props.onAction(action, options);
235244
},
236245
[actions, analytics, dispatch, props]
237246
);
238247

239248
const resultsListRef = useRef<HTMLDivElement>(null);
249+
const modifierKeysRef = useRef({shiftKey: false});
250+
251+
useEffect(() => {
252+
const handleKeyDown = (event: KeyboardEvent) => {
253+
modifierKeysRef.current = {shiftKey: event.shiftKey};
254+
};
255+
256+
const handleKeyUp = (event: KeyboardEvent) => {
257+
modifierKeysRef.current = {shiftKey: event.shiftKey};
258+
};
259+
260+
window.addEventListener('keydown', handleKeyDown);
261+
window.addEventListener('keyup', handleKeyUp);
262+
263+
return () => {
264+
window.removeEventListener('keydown', handleKeyDown);
265+
window.removeEventListener('keyup', handleKeyUp);
266+
};
267+
}, []);
240268

241269
const debouncedQuery = useDebouncedValue(state.query, 300);
242270

@@ -330,7 +358,11 @@ export function CommandPalette(props: CommandPaletteProps) {
330358
}
331359

332360
if (e.key === 'Enter' || e.key === 'Tab') {
333-
onActionSelection(treeState.selectionManager.focusedKey);
361+
// Only forward shiftKey for Enter — Shift+Tab is reverse tab
362+
// navigation, not an "open in new tab" gesture.
363+
onActionSelection(treeState.selectionManager.focusedKey, {
364+
modifierKeys: {shiftKey: e.key === 'Enter' && e.shiftKey},
365+
});
334366
return;
335367
}
336368
},
@@ -383,7 +415,11 @@ export function CommandPalette(props: CommandPaletteProps) {
383415
aria-label={t('Search results')}
384416
selectionMode="none"
385417
shouldUseVirtualFocus
386-
onAction={onActionSelection}
418+
onAction={key => {
419+
onActionSelection(key, {
420+
modifierKeys: modifierKeysRef.current,
421+
});
422+
}}
387423
/>
388424
</ResultsList>
389425
)}
@@ -548,6 +584,20 @@ function flattenActions(
548584
}
549585

550586
function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {
587+
const isExternal = 'to' in action ? isExternalLocation(action.to) : false;
588+
const trailingItems =
589+
'to' in action ? (
590+
<Flex
591+
align="center"
592+
data-link-type={isExternal ? 'external' : 'internal'}
593+
data-test-id="command-palette-link-indicator"
594+
>
595+
<IconDefaultsProvider size="xs" variant="muted">
596+
{isExternal ? <IconOpen /> : <IconLink />}
597+
</IconDefaultsProvider>
598+
</Flex>
599+
) : undefined;
600+
551601
return {
552602
key: action.key,
553603
label: action.display.label,
@@ -565,6 +615,7 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI
565615
<IconDefaultsProvider size="sm">{action.display.icon}</IconDefaultsProvider>
566616
</Flex>
567617
),
618+
trailingItems,
568619
children: [],
569620
hideCheck: true,
570621
};

static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -344,25 +344,19 @@ export function GlobalCommandPaletteActions() {
344344
<CMDKAction display={{label: t('Help')}}>
345345
<CMDKAction
346346
display={{label: t('Open Documentation'), icon: <IconDocs />}}
347-
onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')}
347+
to="https://docs.sentry.io"
348348
/>
349349
<CMDKAction
350350
display={{label: t('Join Discord'), icon: <IconDiscord />}}
351-
onAction={() =>
352-
window.open('https://discord.gg/sentry', '_blank', 'noreferrer')
353-
}
351+
to="https://discord.gg/sentry"
354352
/>
355353
<CMDKAction
356354
display={{label: t('Open GitHub Repository'), icon: <IconGithub />}}
357-
onAction={() =>
358-
window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer')
359-
}
355+
to="https://github.com/getsentry/sentry"
360356
/>
361357
<CMDKAction
362358
display={{label: t('View Changelog'), icon: <IconOpen />}}
363-
onAction={() =>
364-
window.open('https://sentry.io/changelog/', '_blank', 'noreferrer')
365-
}
359+
to="https://sentry.io/changelog/"
366360
/>
367361
<CMDKAction
368362
display={{label: t('Search Results')}}
@@ -391,7 +385,7 @@ export function GlobalCommandPaletteActions() {
391385
keywords: [hit.context?.context1, hit.context?.context2].filter(
392386
(v): v is string => typeof v === 'string'
393387
),
394-
onAction: () => window.open(hit.url, '_blank', 'noreferrer'),
388+
to: hit.url,
395389
});
396390
}
397391
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type {LocationDescriptor} from 'history';
2+
3+
import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location';
4+
5+
export function getLocationHref(to: LocationDescriptor): string {
6+
const resolved = locationDescriptorToTo(to);
7+
8+
if (typeof resolved === 'string') {
9+
return resolved;
10+
}
11+
12+
return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`;
13+
}
14+
15+
export function isExternalLocation(to: LocationDescriptor): boolean {
16+
const currentUrl = new URL(window.location.href);
17+
const targetUrl = new URL(getLocationHref(to), currentUrl.href);
18+
return targetUrl.origin !== currentUrl.origin;
19+
}

static/app/components/commandPalette/ui/modal.spec.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,51 @@ describe('CommandPaletteModal', () => {
157157
// Secondary action is now visible
158158
expect(await screen.findByRole('option', {name: 'Child Action'})).toBeInTheDocument();
159159
});
160+
161+
it('opens external links in a new tab', async () => {
162+
const closeModalSpy = jest.fn();
163+
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
164+
165+
render(
166+
<CommandPaletteProvider>
167+
<SlotOutlets />
168+
<CommandPaletteSlot name="task">
169+
<CMDKAction to="https://docs.sentry.io" display={{label: 'External Link'}} />
170+
</CommandPaletteSlot>
171+
<CommandPaletteModal {...makeRenderProps(closeModalSpy)} />
172+
</CommandPaletteProvider>
173+
);
174+
175+
await userEvent.click(await screen.findByRole('option', {name: 'External Link'}));
176+
177+
expect(openSpy).toHaveBeenCalledWith(
178+
'https://docs.sentry.io',
179+
'_blank',
180+
'noreferrer'
181+
);
182+
expect(closeModalSpy).toHaveBeenCalledTimes(1);
183+
openSpy.mockRestore();
184+
});
185+
186+
it('opens internal links in a new tab when shift-enter is used', async () => {
187+
const closeModalSpy = jest.fn();
188+
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
189+
190+
render(
191+
<CommandPaletteProvider>
192+
<SlotOutlets />
193+
<CommandPaletteSlot name="task">
194+
<CMDKAction to="/target/" display={{label: 'Internal Link'}} />
195+
</CommandPaletteSlot>
196+
<CommandPaletteModal {...makeRenderProps(closeModalSpy)} />
197+
</CommandPaletteProvider>
198+
);
199+
200+
await screen.findByRole('textbox', {name: 'Search commands'});
201+
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
202+
203+
expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer');
204+
expect(closeModalSpy).toHaveBeenCalledTimes(1);
205+
openSpy.mockRestore();
206+
});
160207
});

static/app/components/commandPalette/ui/modal.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal';
55
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
66
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
77
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
8+
import {
9+
getLocationHref,
10+
isExternalLocation,
11+
} from 'sentry/components/commandPalette/ui/locationUtils';
812
import type {Theme} from 'sentry/utils/theme';
913
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
1014
import {useNavigate} from 'sentry/utils/useNavigate';
@@ -13,9 +17,17 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps
1317
const navigate = useNavigate();
1418

1519
const handleSelect = useCallback(
16-
(action: CollectionTreeNode<CMDKActionData>) => {
20+
(
21+
action: CollectionTreeNode<CMDKActionData>,
22+
options?: {modifierKeys?: {shiftKey: boolean}}
23+
) => {
1724
if ('to' in action) {
18-
navigate(normalizeUrl(action.to));
25+
const normalizedTo = normalizeUrl(action.to);
26+
if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) {
27+
window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer');
28+
} else {
29+
navigate(normalizedTo);
30+
}
1931
} else if ('onAction' in action) {
2032
// When the action has children, the palette will push into them so the
2133
// user can select a secondary action — keep the modal open. The

0 commit comments

Comments
 (0)