diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 51974f46ef50..aa6ae4360076 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -143,7 +143,8 @@ export function CommandPalette({ const debouncedQuery = useDebouncedValue(state.query, 300); const isFetchingQueries = useIsFetching({predicate: q => q.meta?.cmdk === true}); const isLoading = - (state.query.length > 0 && debouncedQuery !== state.query) || isFetchingQueries > 0; + state.list === 'active' && + ((state.query.length > 0 && debouncedQuery !== state.query) || isFetchingQueries > 0); const isEmptyPromptQuery = state.action?.value.prompt !== undefined && (state.query.length === 0 || isLoading); @@ -153,7 +154,7 @@ export function CommandPalette({ return nodes; }, [store, state.action]); - const [actions, prefixMap, isSeerFallback] = useMemo< + const [computedActions, computedPrefixMap, computedIsSeerFallback] = useMemo< [CMDKFlatItem[], Map, boolean] >(() => { const [scored, scoredPrefixMap] = state.query @@ -225,6 +226,28 @@ export function CommandPalette({ openForm, ]); + const frozenRef = useRef({ + actions: computedActions, + prefixMap: computedPrefixMap, + isSeerFallback: computedIsSeerFallback, + }); + + useEffect(() => { + if (state.list === 'active') { + frozenRef.current = { + actions: computedActions, + prefixMap: computedPrefixMap, + isSeerFallback: computedIsSeerFallback, + }; + } + }, [state.list, computedActions, computedPrefixMap, computedIsSeerFallback]); + + const actions = state.list === 'active' ? computedActions : frozenRef.current.actions; + const prefixMap = + state.list === 'active' ? computedPrefixMap : frozenRef.current.prefixMap; + const isSeerFallback = + state.list === 'active' ? computedIsSeerFallback : frozenRef.current.isSeerFallback; + const analytics = useCommandPaletteAnalytics(isSeerFallback ? 0 : actions.length); const mouseLeftResultsRef = useRef(false); @@ -365,6 +388,10 @@ export function CommandPalette({ } }, onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + dispatch({type: 'freeze list'}); + } + if ( treeState.selectionManager.focusedKey === null && (e.key === 'ArrowDown' || e.key === 'ArrowUp') diff --git a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx index bdc20c232588..70f41200d435 100644 --- a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx @@ -22,6 +22,10 @@ export type CMDKNavStack = { export type CommandPaletteState = { action: CMDKNavStack | null; input: React.RefObject; + // Controls whether the rendered action list updates from the collection store. + // 'frozen' keeps the visible list stable while the user navigates with the + // keyboard. Any other dispatched action resets to 'active'. + list: 'active' | 'frozen'; open: boolean; // When true, action and query are cleared the next time the modal opens. // Set by 'trigger action' so the close animation plays without a jarring @@ -49,7 +53,8 @@ type CommandPaletteAction = } | {type: 'trigger action'} | {type: 'pop action'} - | {type: 'reset on open'}; + | {type: 'reset on open'} + | {type: 'freeze list'}; const CommandPaletteStateContext = createContext(null); const CommandPaletteDispatchContext = @@ -61,6 +66,8 @@ function commandPaletteReducer( ): CommandPaletteState { const type = action.type; switch (type) { + case 'freeze list': + return {...state, list: 'frozen'}; case 'toggle modal': if (!state.open && state.resetOnOpen) { return { @@ -70,11 +77,13 @@ function commandPaletteReducer( query: '', resetOnOpen: false, pendingReset: false, + list: 'active', }; } return { ...state, open: !state.open, + list: 'active', }; case 'reset': return { @@ -83,11 +92,12 @@ function commandPaletteReducer( query: '', pendingReset: false, resetOnOpen: false, + list: 'active', }; case 'reset on open': - return {...state, resetOnOpen: true}; + return {...state, resetOnOpen: true, list: 'active'}; case 'set query': - return {...state, query: action.query}; + return {...state, query: action.query, list: 'active'}; case 'push action': return { ...state, @@ -101,15 +111,17 @@ function commandPaletteReducer( previous: state.action, }, query: action.query ?? '', + list: 'active', }; case 'pop action': return { ...state, action: state.action?.previous ?? null, query: state.action?.value?.query ?? state.query, + list: 'active', }; case 'trigger action': - return {...state, pendingReset: true}; + return {...state, pendingReset: true, list: 'active'}; default: unreachable(type); return state; @@ -149,6 +161,7 @@ export function CommandPaletteStateProvider({ open: false, pendingReset: false, resetOnOpen: false, + list: 'active', }); return (