Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
7b1717b
ref(cmdk): implement typed collection factory for JSX-based action re…
JonasBa Apr 6, 2026
39a8156
ref(cmdk): migrate command palette to JSX collection model (steps 2–5)
JonasBa Apr 6, 2026
c06aae5
ref(cmdk): pre-review cleanup
JonasBa Apr 6, 2026
a009877
remove redundant context
JonasBa Apr 6, 2026
b821641
ref(cmdk): remove useGlobalCommandPaletteActions
JonasBa Apr 6, 2026
a8b1229
ref(cmdk): delete context.tsx, move CommandPaletteProvider to cmdk.tsx
JonasBa Apr 6, 2026
dbca928
ref(cmdk): move GlobalCommandPaletteActions into modal
JonasBa Apr 6, 2026
c1b7b0e
ref(cmdk): CommandPalette accepts children for action registration
JonasBa Apr 6, 2026
a5330eb
fix(cmdk): pass LocationDescriptor directly instead of stringifying
JonasBa Apr 6, 2026
12c8da6
fix(cmdk): guard nav sidebar toggle against missing SecondaryNavigati…
JonasBa Apr 6, 2026
5585fc2
fix(cmdk): restore GlobalCommandPaletteActions to navigation scope
JonasBa Apr 6, 2026
a2107d7
ref(cmdk) add slot context
JonasBa Apr 6, 2026
1583240
ref(cmdk) add issues list actions
JonasBa Apr 6, 2026
ddcc461
test(cmdk): Add failing test for slot rendering priority and fix miss…
JonasBa Apr 6, 2026
d4e45f2
docs(cmdk): Add implementation plan for slot-priority pre-sort
JonasBa Apr 6, 2026
162d409
feat(cmdk): Sort actions by slot priority using outlet DOM position
JonasBa Apr 6, 2026
406e56e
feat(cmdk): Wire slot priority sorting into collection and palette
JonasBa Apr 6, 2026
d97b598
ref(cmdk) wip
JonasBa Apr 6, 2026
562a106
ref(cmdk) cleanup
JonasBa Apr 6, 2026
05468ef
ref(cmdk) implement proper actions
JonasBa Apr 6, 2026
eef1471
fix(cmdk): Gate DSN lookup query behind DSN_PATTERN check
JonasBa Apr 6, 2026
dce75f9
Merge origin/master into jb/cmdk/jsx-poc
JonasBa Apr 6, 2026
0158ce5
ref(cmdk) revert issues list poc
JonasBa Apr 6, 2026
4406dc2
docs(cmdk): Update story to JSX-powered command palette API
JonasBa Apr 7, 2026
9a0a7c6
ref(cmdk): Merge CMDKGroup and CMDKAction into single CMDKAction comp…
JonasBa Apr 9, 2026
9d70bc8
fix(cmdk): Omit empty groups and reset scroll on query change (#112325)
JonasBa Apr 9, 2026
497dd7e
feat(cmdk): Invoke onAction callback for actions with children
JonasBa Apr 9, 2026
e2b0710
fix(cmdk): Use render prop closeModal to properly reset open state
JonasBa Apr 9, 2026
dd0b8b9
perf(cmdk): Score each candidate field individually in scoreNode
JonasBa Apr 9, 2026
f1a1a06
fix(cmdk): Address bugbot findings — admin actions, DSN icon, String …
JonasBa Apr 9, 2026
c56f2d2
fix(cmdk): Remove String() coercion in story component
JonasBa Apr 9, 2026
7508757
fix(slot): Render nothing when no outlet is registered (#112568)
JonasBa Apr 9, 2026
9b83079
Merge branch 'master' into jb/cmdk/jsx-poc
JonasBa Apr 9, 2026
f473658
Merge branch 'master' into jb/cmdk/jsx-poc
JonasBa Apr 9, 2026
fa3d675
remove unused exports
JonasBa Apr 9, 2026
e198a91
ref(cmdk): Remove ActionsToJSX adapter and CommandPaletteAsyncResult …
JonasBa Apr 9, 2026
cd2aa33
ref(cmdk) move admin actions
JonasBa Apr 9, 2026
88b2723
fix(cmdk): Keep CMDKAction nodes mounted across modal open/close cycl…
JonasBa Apr 10, 2026
20b13f9
Merge branch 'master' into jb/cmdk/jsx-poc
JonasBa Apr 10, 2026
006c238
fix(slot): Restore null return when no outlet is registered
JonasBa Apr 10, 2026
74181d8
remove duplicate import
JonasBa Apr 10, 2026
ba77192
fix(cmdk): Avoid double-calling expandable actions
JonasBa Apr 10, 2026
6c1219d
fix reverse lookup
JonasBa Apr 10, 2026
c153ff0
feat(cmdk): Add prompt prop to resource actions for input-driven sub-…
JonasBa Apr 10, 2026
d4c46a2
fix(cmdk): Include prompt/resource nodes in browse-mode filter
JonasBa Apr 11, 2026
a288588
ref(cmdk) add link detection
JonasBa Apr 9, 2026
bfc45aa
feat(cmdk): Support tab-opening modifiers for links
JonasBa Apr 9, 2026
1e9dd17
test(cmdk): Align shift-link coverage with keyboard flow
JonasBa Apr 9, 2026
3ce5542
ref(cmdk) simplify keyboard
JonasBa Apr 9, 2026
a9bd667
fix(cmdk): Restore new-tab behavior for admin links
JonasBa Apr 9, 2026
83d6a0c
ref(cmdk): Extract getLocationHref and isExternalLocation to shared l…
JonasBa Apr 10, 2026
41c1b04
fix(cmdk): Call action.onAction() directly for group actions
JonasBa Apr 12, 2026
e609d1d
Merge origin/master into jb/cmdk/item-links
JonasBa Apr 13, 2026
12d0610
ref(cmdk) remove shift key reset
JonasBa Apr 13, 2026
9694686
fix(cmdk): Restrict shiftKey modifier to Enter key only
JonasBa Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export function CommandPaletteDemo() {
const navigate = useNavigate();

const handleAction = useCallback(
(action: CollectionTreeNode<CMDKActionData>) => {
(
action: CollectionTreeNode<CMDKActionData>,
_options?: {modifierKeys?: {shiftKey: boolean}}
) => {
if ('to' in action) {
navigate(normalizeUrl(action.to));
} else if ('onAction' in action) {
Expand Down
62 changes: 58 additions & 4 deletions static/app/components/commandPalette/ui/commandPalette.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,33 @@ import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
import {useNavigate} from 'sentry/utils/useNavigate';

function GlobalActionsComponent({children}: {children?: React.ReactNode}) {
function GlobalActionsComponent({
children,
onAction,
}: {
children?: React.ReactNode;
onAction?: (
action: CollectionTreeNode<CMDKActionData>,
options?: {modifierKeys?: {shiftKey: boolean}}
) => void;
}) {
const navigate = useNavigate();

const handleAction = useCallback(
(action: CollectionTreeNode<CMDKActionData>) => {
if ('to' in action) {
(
action: CollectionTreeNode<CMDKActionData>,
options?: {modifierKeys?: {shiftKey: boolean}}
) => {
if (onAction) {
onAction(action, options);
} else if ('to' in action) {
navigate(action.to);
} else if ('onAction' in action) {
action.onAction();
}
closeModal();
},
[navigate]
[navigate, onAction]
);

return (
Expand Down Expand Up @@ -104,6 +118,46 @@ describe('CommandPalette', () => {
expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('shift-enter on an internal link forwards modifier keys and closes modal', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
const onAction = jest.fn();

render(
<GlobalActionsComponent onAction={onAction}>
<CMDKAction to="/target/" display={{label: 'Go to route'}} />
</GlobalActionsComponent>
);

await screen.findByRole('textbox', {name: 'Search commands'});
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');

expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), {
modifierKeys: {shiftKey: true},
});
expect(closeSpy).toHaveBeenCalledTimes(1);
});

it('shows internal and external trailing link indicators for link actions', async () => {
render(
<GlobalActionsComponent>
<Fragment>
<CMDKAction to="/target/" display={{label: 'Internal'}} />
<CMDKAction to="https://docs.sentry.io" display={{label: 'External'}} />
</Fragment>
</GlobalActionsComponent>
);

const internalAction = await screen.findByRole('option', {name: 'Internal'});
const externalAction = await screen.findByRole('option', {name: 'External'});

expect(
internalAction.querySelector('[data-test-id="command-palette-link-indicator"]')
).toHaveAttribute('data-link-type', 'internal');
expect(
externalAction.querySelector('[data-test-id="command-palette-link-indicator"]')
).toHaveAttribute('data-link-type', 'external');
});

it('clicking action with children shows sub-items, backspace returns', async () => {
const closeSpy = jest.spyOn(modalActions, 'closeModal');
render(
Expand Down
67 changes: 59 additions & 8 deletions static/app/components/commandPalette/ui/commandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react';
import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react';
import {preload} from 'react-dom';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
Expand Down Expand Up @@ -26,10 +26,11 @@ import {
useCommandPaletteDispatch,
useCommandPaletteState,
} from 'sentry/components/commandPalette/ui/commandPaletteStateContext';
import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils';
import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics';
import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconArrow, IconClose, IconSearch} from 'sentry/icons';
import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons';
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
import {t} from 'sentry/locale';
import {fzf} from 'sentry/utils/search/fzf';
Expand Down Expand Up @@ -64,7 +65,10 @@ type CMDKFlatItem = CollectionTreeNode<CMDKActionData> & {
};

interface CommandPaletteProps {
onAction: (action: CollectionTreeNode<CMDKActionData>) => void;
onAction: (
action: CollectionTreeNode<CMDKActionData>,
options?: {modifierKeys?: {shiftKey: boolean}}
) => void;
}

export function CommandPalette(props: CommandPaletteProps) {
Expand Down Expand Up @@ -200,7 +204,12 @@ export function CommandPalette(props: CommandPaletteProps) {
});

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

analytics.recordAction(action, resultIndex, '');
dispatch({type: 'trigger action'});
props.onAction(action);
props.onAction(action, options);
},
[actions, analytics, dispatch, props]
);

const resultsListRef = useRef<HTMLDivElement>(null);
const modifierKeysRef = useRef({shiftKey: false});

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
modifierKeysRef.current = {shiftKey: event.shiftKey};
};

const handleKeyUp = (event: KeyboardEvent) => {
modifierKeysRef.current = {shiftKey: event.shiftKey};
};

window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);

return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);

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

Expand Down Expand Up @@ -328,7 +356,11 @@ export function CommandPalette(props: CommandPaletteProps) {
}

if (e.key === 'Enter' || e.key === 'Tab') {
onActionSelection(treeState.selectionManager.focusedKey);
// Only forward shiftKey for Enter — Shift+Tab is reverse tab
// navigation, not an "open in new tab" gesture.
onActionSelection(treeState.selectionManager.focusedKey, {
modifierKeys: {shiftKey: e.key === 'Enter' && e.shiftKey},
});
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}
},
Expand Down Expand Up @@ -379,7 +411,11 @@ export function CommandPalette(props: CommandPaletteProps) {
aria-label={t('Search results')}
selectionMode="none"
shouldUseVirtualFocus
onAction={onActionSelection}
onAction={key => {
onActionSelection(key, {
modifierKeys: modifierKeysRef.current,
});
}}
/>
</ResultsList>
)}
Expand Down Expand Up @@ -544,6 +580,20 @@ function flattenActions(
}

function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem {
const isExternal = 'to' in action ? isExternalLocation(action.to) : false;
const trailingItems =
'to' in action ? (
<Flex
align="center"
data-link-type={isExternal ? 'external' : 'internal'}
data-test-id="command-palette-link-indicator"
>
<IconDefaultsProvider size="xs" variant="muted">
{isExternal ? <IconOpen /> : <IconLink />}
</IconDefaultsProvider>
</Flex>
) : undefined;
Comment thread
sentry[bot] marked this conversation as resolved.

return {
key: action.key,
label: action.display.label,
Expand All @@ -561,6 +611,7 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI
<IconDefaultsProvider size="sm">{action.display.icon}</IconDefaultsProvider>
</Flex>
),
trailingItems,
children: [],
hideCheck: true,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,25 +344,19 @@ export function GlobalCommandPaletteActions() {
<CMDKAction display={{label: t('Help')}}>
<CMDKAction
display={{label: t('Open Documentation'), icon: <IconDocs />}}
onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')}
to="https://docs.sentry.io"
/>
<CMDKAction
display={{label: t('Join Discord'), icon: <IconDiscord />}}
onAction={() =>
window.open('https://discord.gg/sentry', '_blank', 'noreferrer')
}
to="https://discord.gg/sentry"
/>
<CMDKAction
display={{label: t('Open GitHub Repository'), icon: <IconGithub />}}
onAction={() =>
window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer')
}
to="https://github.com/getsentry/sentry"
/>
<CMDKAction
display={{label: t('View Changelog'), icon: <IconOpen />}}
onAction={() =>
window.open('https://sentry.io/changelog/', '_blank', 'noreferrer')
}
to="https://sentry.io/changelog/"
/>
<CMDKAction
display={{label: t('Search Results')}}
Expand Down Expand Up @@ -391,7 +385,7 @@ export function GlobalCommandPaletteActions() {
keywords: [hit.context?.context1, hit.context?.context2].filter(
(v): v is string => typeof v === 'string'
),
onAction: () => window.open(hit.url, '_blank', 'noreferrer'),
to: hit.url,
});
}
}
Expand Down
19 changes: 19 additions & 0 deletions static/app/components/commandPalette/ui/locationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {LocationDescriptor} from 'history';

import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location';

export function getLocationHref(to: LocationDescriptor): string {
const resolved = locationDescriptorToTo(to);

if (typeof resolved === 'string') {
return resolved;
}

return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`;
}

export function isExternalLocation(to: LocationDescriptor): boolean {
const currentUrl = new URL(window.location.href);
const targetUrl = new URL(getLocationHref(to), currentUrl.href);
return targetUrl.origin !== currentUrl.origin;
Comment thread
JonasBa marked this conversation as resolved.
}
47 changes: 47 additions & 0 deletions static/app/components/commandPalette/ui/modal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,51 @@ describe('CommandPaletteModal', () => {
// Secondary action is now visible
expect(await screen.findByRole('option', {name: 'Child Action'})).toBeInTheDocument();
});

it('opens external links in a new tab', async () => {
const closeModalSpy = jest.fn();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);

render(
<CommandPaletteProvider>
<SlotOutlets />
<CommandPaletteSlot name="task">
<CMDKAction to="https://docs.sentry.io" display={{label: 'External Link'}} />
</CommandPaletteSlot>
<CommandPaletteModal {...makeRenderProps(closeModalSpy)} />
</CommandPaletteProvider>
);

await userEvent.click(await screen.findByRole('option', {name: 'External Link'}));

expect(openSpy).toHaveBeenCalledWith(
'https://docs.sentry.io',
'_blank',
'noreferrer'
);
expect(closeModalSpy).toHaveBeenCalledTimes(1);
openSpy.mockRestore();
});

it('opens internal links in a new tab when shift-enter is used', async () => {
const closeModalSpy = jest.fn();
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);

render(
<CommandPaletteProvider>
<SlotOutlets />
<CommandPaletteSlot name="task">
<CMDKAction to="/target/" display={{label: 'Internal Link'}} />
</CommandPaletteSlot>
<CommandPaletteModal {...makeRenderProps(closeModalSpy)} />
</CommandPaletteProvider>
);

await screen.findByRole('textbox', {name: 'Search commands'});
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');

expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer');
expect(closeModalSpy).toHaveBeenCalledTimes(1);
openSpy.mockRestore();
});
});
16 changes: 14 additions & 2 deletions static/app/components/commandPalette/ui/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk';
import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection';
import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette';
import {
getLocationHref,
isExternalLocation,
} from 'sentry/components/commandPalette/ui/locationUtils';
import type {Theme} from 'sentry/utils/theme';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useNavigate} from 'sentry/utils/useNavigate';
Expand All @@ -13,9 +17,17 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps
const navigate = useNavigate();

const handleSelect = useCallback(
(action: CollectionTreeNode<CMDKActionData>) => {
(
action: CollectionTreeNode<CMDKActionData>,
options?: {modifierKeys?: {shiftKey: boolean}}
) => {
if ('to' in action) {
navigate(normalizeUrl(action.to));
const normalizedTo = normalizeUrl(action.to);
if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) {
window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer');
Comment thread
cursor[bot] marked this conversation as resolved.
} else {
navigate(normalizedTo);
}
Comment thread
sentry[bot] marked this conversation as resolved.
} else if ('onAction' in action) {
// When the action has children, the palette will push into them so the
// user can select a secondary action — keep the modal open. The
Expand Down
Loading