Skip to content

feat(nav): Allow Cmd+K to toggle command palette open and closed#111441

Open
jshchnz wants to merge 1 commit intomasterfrom
feat/cmd-k-toggle
Open

feat(nav): Allow Cmd+K to toggle command palette open and closed#111441
jshchnz wants to merge 1 commit intomasterfrom
feat/cmd-k-toggle

Conversation

@jshchnz
Copy link
Copy Markdown
Member

@jshchnz jshchnz commented Mar 24, 2026

Summary

  • Cmd+K (and Ctrl+K, Cmd+Shift+P, Ctrl+Shift+P) now toggles the new command palette open and closed, instead of only opening it
  • Search query is preserved across open/close cycles so reopening the palette restores the previous search text
  • Only affects the new command palette behind cmd-k-supercharged feature flag; the deprecated palette retains its existing open-only behavior

How it works

  • A ref tracks whether the command palette is the currently active modal
  • The ref resets when the modal closes by any means (Escape, backdrop click, route change)
  • includeInputs: true ensures the shortcut fires even while the palette's search input is focused
  • A module-level variable in useCommandPaletteState persists the search query across mount/unmount cycles

Test plan

  • Press Cmd+K → palette opens
  • Press Cmd+K again → palette closes
  • Type a search query, press Cmd+K to close, press Cmd+K to reopen → query is preserved
  • Press Escape to close → Cmd+K reopens with preserved query
  • Open a different modal → Cmd+K does nothing (doesn't close unrelated modals)
  • Without cmd-k-supercharged flag → old palette opens only, no toggle behavior

Previously Cmd+K could only open the command palette. Pressing it again
while the palette was open had no effect because the hotkey was disabled
when any modal was visible.

Now Cmd+K toggles the new command palette (behind cmd-k-supercharged flag):
- Tracks whether the command palette is the active modal via a ref
- Closes the palette if it's already open, opens it if no modal is open
- Does nothing if a different modal is open
- Uses includeInputs so the shortcut works while the search input is focused
- Persists the search query across open/close cycles via a module-level variable
@jshchnz jshchnz requested a review from a team as a code owner March 24, 2026 18:58
@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Mar 24, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: includeInputs unintentionally changes deprecated palette shortcut behavior
    • I scoped includeInputs to only the supercharged path so deprecated palette shortcuts are again ignored while typing in inputs.
  • ✅ Fixed: Ref not reset when modal is replaced, not closed
    • I track the active modal renderer and clear command-palette-open state when the renderer changes while visible, preventing Cmd+K from closing unrelated replacement modals.

Create PR

Or push these changes by commenting:

@cursor push 0f27e0eac8
Preview (0f27e0eac8)
diff --git a/static/app/views/navigation/index.desktop.spec.tsx b/static/app/views/navigation/index.desktop.spec.tsx
--- a/static/app/views/navigation/index.desktop.spec.tsx
+++ b/static/app/views/navigation/index.desktop.spec.tsx
@@ -13,7 +13,9 @@
   type RouterConfig,
 } from 'sentry-test/reactTestingLibrary';
 
+import * as ModalActionCreators from 'sentry/actionCreators/modal';
 import {ConfigStore} from 'sentry/stores/configStore';
+import {ModalStore} from 'sentry/stores/modalStore';
 import {Navigation} from 'sentry/views/navigation';
 import {NAVIGATION_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY} from 'sentry/views/navigation/constants';
 import {PrimaryNavigationContextProvider} from 'sentry/views/navigation/primaryNavigationContext';
@@ -860,4 +862,68 @@
       });
     });
   });
+
+  describe('command palette hotkeys', () => {
+    it('does not open deprecated command palette when an input has focus', async () => {
+      const openCommandPaletteDeprecatedSpy = jest
+        .spyOn(ModalActionCreators, 'openCommandPaletteDeprecated')
+        .mockResolvedValue();
+
+      const input = document.createElement('input');
+      document.body.appendChild(input);
+      input.focus();
+
+      render(
+        <PrimaryNavigationContextProvider>
+          <Navigation />
+        </PrimaryNavigationContextProvider>,
+        navigationContext()
+      );
+
+      await userEvent.keyboard('{Control>}k{/Control}');
+
+      expect(openCommandPaletteDeprecatedSpy).not.toHaveBeenCalled();
+
+      openCommandPaletteDeprecatedSpy.mockRestore();
+      input.remove();
+    });
+
+    it('does not close a replacement modal after command palette is replaced', async () => {
+      ModalStore.closeModal();
+
+      const commandPaletteRenderer = jest.fn(() => null);
+      const replacementModalRenderer = jest.fn(() => null);
+      const openCommandPaletteSpy = jest
+        .spyOn(ModalActionCreators, 'openCommandPalette')
+        .mockImplementation(() => {
+          ModalStore.openModal(commandPaletteRenderer, {});
+          return Promise.resolve();
+        });
+
+      render(
+        <PrimaryNavigationContextProvider>
+          <Navigation />
+        </PrimaryNavigationContextProvider>,
+        navigationContext({
+          organization: {
+            features: [...ALL_AVAILABLE_FEATURES, 'cmd-k-supercharged'],
+          },
+        })
+      );
+
+      await userEvent.keyboard('{Control>}k{/Control}');
+      expect(ModalStore.getState().renderer).toBe(commandPaletteRenderer);
+
+      ModalStore.openModal(replacementModalRenderer, {});
+      await waitFor(() => {
+        expect(ModalStore.getState().renderer).toBe(replacementModalRenderer);
+      });
+
+      await userEvent.keyboard('{Control>}k{/Control}');
+      expect(ModalStore.getState().renderer).toBe(replacementModalRenderer);
+
+      openCommandPaletteSpy.mockRestore();
+      ModalStore.closeModal();
+    });
+  });
 });

diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx
--- a/static/app/views/navigation/index.tsx
+++ b/static/app/views/navigation/index.tsx
@@ -33,30 +33,50 @@
 function UserAndOrganizationNavigation() {
   const organization = useOrganization();
   const {layout} = usePrimaryNavigation();
-  const {visible} = useGlobalModal();
+  const {visible, renderer} = useGlobalModal();
   const hasPageFrame = useHasPageFrameFeature();
 
   useGlobalCommandPaletteActions();
 
   const commandPaletteOpenRef = useRef(false);
+  const commandPaletteRendererRef = useRef<typeof renderer>(null);
+  const hasSuperchargedCommandPalette =
+    organization.features.includes('cmd-k-supercharged');
 
   useEffect(() => {
     if (!visible) {
       commandPaletteOpenRef.current = false;
+      commandPaletteRendererRef.current = null;
+      return;
     }
-  }, [visible]);
 
+    if (!commandPaletteOpenRef.current || !renderer) {
+      return;
+    }
+
+    if (commandPaletteRendererRef.current === null) {
+      commandPaletteRendererRef.current = renderer;
+      return;
+    }
+
+    if (commandPaletteRendererRef.current !== renderer) {
+      commandPaletteOpenRef.current = false;
+      commandPaletteRendererRef.current = null;
+    }
+  }, [visible, renderer]);
+
   useHotkeys([
     {
       match: ['command+shift+p', 'command+k', 'ctrl+shift+p', 'ctrl+k'],
-      includeInputs: true,
+      includeInputs: hasSuperchargedCommandPalette,
       callback: () => {
-        if (organization.features.includes('cmd-k-supercharged')) {
+        if (hasSuperchargedCommandPalette) {
           if (visible && commandPaletteOpenRef.current) {
             closeModal();
           } else if (!visible) {
             openCommandPalette();
             commandPaletteOpenRef.current = true;
+            commandPaletteRendererRef.current = null;
           }
         } else if (!visible) {
           openCommandPaletteDeprecated();

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

useHotkeys([
{
match: ['command+shift+p', 'command+k', 'ctrl+shift+p', 'ctrl+k'],
includeInputs: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includeInputs unintentionally changes deprecated palette shortcut behavior

Medium Severity

The includeInputs: true option is set on the single hotkey definition that covers both the supercharged and deprecated palette paths. Previously, the shortcut was suppressed when an input or textarea had focus. Now, for users without the cmd-k-supercharged flag, pressing Cmd+K while typing in any text field will unexpectedly open the deprecated command palette. The PR states the deprecated palette retains its existing behavior, but this changes how the shortcut is triggered.

Additional Locations (1)
Fix in Cursor Fix in Web

if (!visible) {
commandPaletteOpenRef.current = false;
}
}, [visible]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref not reset when modal is replaced, not closed

Medium Severity

commandPaletteOpenRef is only reset when visible becomes false, but ModalStore.openModal directly replaces the renderer without clearing it first, so visible stays true when one modal replaces another. If the command palette is open and an action opens a different modal, commandPaletteOpenRef.current remains true. A subsequent Cmd+K press would then incorrectly call closeModal() on the unrelated modal instead of doing nothing.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might use a query selector to find out if its currently on the screen or not

Comment on lines +73 to +77
const [query, _setQuery] = useState(persistedQuery);
const setQuery = useCallback((newQuery: string) => {
persistedQuery = newQuery;
_setQuery(newQuery);
}, []);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be able to use useSessionStorage here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would likely want to clear the string when closing normally


export function useCommandPaletteState() {
const [query, setQuery] = useState('');
const [query, _setQuery] = useState(persistedQuery);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should hoist cmd+k state into a context provider outside of the modal, then we get the query string storage for free, and all we need to observe is the toggle behavior.

@rbro112 might know better, but iirc we already have a global cmd k provider where we could hoist this state into

@JonasBa
Copy link
Copy Markdown
Member

JonasBa commented Mar 25, 2026

@jshchnz @ryan953 my implementation here achieves the state persistence by hoisting state. I think we should use that state to store the active focus ref, and return the focus as well since it will be durable and persist across renders (it also means we can isolate that piece of logic away from the UI itself)

@getsantry
Copy link
Copy Markdown
Contributor

getsantry bot commented Apr 16, 2026

This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you remove the label Waiting for: Community, I will leave it alone ... forever!


"A weed is but an unloved flower." ― Ella Wheeler Wilcox 🥀

@getsantry getsantry bot added the Stale label Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants