From 449601156688d9d80b966165595746a8be5e5c5e Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 06:48:21 +0200 Subject: [PATCH 1/7] ref(issues) cmdk actions --- .../commandPalette/ui/commandPalette.tsx | 30 ++- .../ui/commandPaletteGlobalActions.tsx | 76 ++++---- .../ui/commandPaletteStateContext.tsx | 13 +- static/app/components/core/slot/slot.spec.tsx | 15 +- static/app/components/core/slot/slot.tsx | 26 ++- static/app/views/issueList/actions/index.tsx | 182 ++++++++++++------ 6 files changed, 224 insertions(+), 118 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 5900bc7cb7b793..d4a8911b3b9c6a 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -90,7 +90,7 @@ export function CommandPalette(props: CommandPaletteProps) { const actions = useMemo(() => { if (!state.query) { - return flattenActions(currentNodes, null); + return flattenActions(currentNodes, null, !state.action); } const scores = new Map< @@ -99,7 +99,7 @@ export function CommandPalette(props: CommandPaletteProps) { >(); scoreTree(currentNodes, scores, state.query.toLowerCase()); return flattenActions(currentNodes, scores); - }, [currentNodes, state.query]); + }, [currentNodes, state.query, state.action]); const analytics = useCommandPaletteAnalytics(actions.length); @@ -459,12 +459,21 @@ function scoreTree( } } +// Maximum number of children to show per group, in both browse and search mode. +// Prevents groups with many items (e.g. per-project settings on large orgs) +// from flooding the results list. +const MAX_GROUP_CHILDREN = 4; + function flattenActions( nodes: Array>, scores: Map< string, {node: CollectionTreeNode; score: {matched: boolean; score: number}} - > | null + > | null, + // Only expand groups inline at the true root level. When the user has + // navigated into a group, nested groups should appear as navigable actions + // rather than being pre-expanded as section headers with their children. + expandGroups = true ): CMDKFlatItem[] { // Browse mode: show each top-level node and its direct children. if (!scores) { @@ -476,11 +485,19 @@ function flattenActions( if (!isGroup && !('to' in node) && !('onAction' in node)) { continue; } - results.push({...node, listItemType: isGroup ? 'section' : 'action'}); - if (isGroup) { - for (const child of node.children) { + if (isGroup && expandGroups) { + // Expand the group inline: render it as a section header followed by + // its direct children. This only happens at the true root level so + // that nested groups (e.g. "Set Priority" inside "Select All") are + // shown as navigable actions rather than pre-expanded sections. + results.push({...node, listItemType: 'section'}); + for (const child of node.children.slice(0, MAX_GROUP_CHILDREN)) { results.push({...child, listItemType: 'action'}); } + } else { + // Leaf action or a group that should not be expanded inline — treat + // as a single navigable item regardless. + results.push({...node, listItemType: 'action'}); } } return results; @@ -526,6 +543,7 @@ function flattenActions( (scores.get(b.key)?.score.score ?? 0) - (scores.get(a.key)?.score.score ?? 0) ) + .slice(0, MAX_GROUP_CHILDREN) .map(c => ({...c, listItemType: 'action' as const})), ]; } diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 31c7dab78a98c3..a3cae75f2fc0f8 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -220,6 +220,44 @@ export function GlobalCommandPaletteActions() { /> ))} + + {user.isStaff && ( + + }} + keywords={[t('superuser')]} + onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} + /> + , + }} + keywords={[t('superuser')]} + onAction={() => + window.open( + `/_admin/customers/${organization.slug}/`, + '_blank', + 'noreferrer' + ) + } + /> + {!isActiveSuperuser() && ( + }} + keywords={[t('superuser')]} + onAction={() => openSudo({isSuperuser: true, needsReload: true})} + /> + )} + {isActiveSuperuser() && ( + }} + keywords={[t('superuser')]} + onAction={() => exitSuperuser()} + /> + )} + + )} @@ -395,44 +433,6 @@ export function GlobalCommandPaletteActions() { /> - - {user.isStaff && ( - - }} - keywords={[t('superuser')]} - onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} - /> - , - }} - keywords={[t('superuser')]} - onAction={() => - window.open( - `/_admin/customers/${organization.slug}/`, - '_blank', - 'noreferrer' - ) - } - /> - {!isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => openSudo({isSuperuser: true, needsReload: true})} - /> - )} - {isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => exitSuperuser()} - /> - )} - - )} ); } diff --git a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx index b50c37a1de0ae7..9be341f229cab6 100644 --- a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx @@ -46,11 +46,20 @@ function commandPaletteReducer( ): CommandPaletteState { const type = action.type; switch (type) { - case 'toggle modal': + case 'toggle modal': { + const nextOpen = !state.open; return { ...state, - open: !state.open, + open: nextOpen, + // Reset navigation and query when opening. The slot system portals + // CMDKAction children into the outlet's DOM element, so switching + // between portal and in-place rendering causes React to remount those + // components with fresh useId() keys. Any nav-stack key stored from a + // prior session is therefore stale — resetting here ensures the palette + // always opens at the root. + ...(nextOpen ? {action: null, query: ''} : {}), }; + } case 'reset': return { ...state, diff --git a/static/app/components/core/slot/slot.spec.tsx b/static/app/components/core/slot/slot.spec.tsx index d23b461d357cda..942622e089bd2a 100644 --- a/static/app/components/core/slot/slot.spec.tsx +++ b/static/app/components/core/slot/slot.spec.tsx @@ -11,7 +11,12 @@ describe('slot', () => { expect(SlotModule.Fallback).toBeDefined(); }); - it('renders children in place when no Outlet is registered', () => { + it('hides children in a detached container when no Outlet is registered', () => { + // Children are always portaled — to a stable hidden div before the outlet + // mounts, to the real outlet element once it registers. This prevents the + // in-place → portal type switch that would remount the subtree and lose + // component state (e.g. useId() keys). Content is intentionally invisible + // until an outlet is available. const SlotModule = slot(['header'] as const); render( @@ -22,7 +27,7 @@ describe('slot', () => { ); - expect(screen.getByText('inline content')).toBeInTheDocument(); + expect(screen.queryByText('inline content')).not.toBeInTheDocument(); }); it('portals children to the Outlet element', () => { @@ -44,7 +49,7 @@ describe('slot', () => { ); }); - it('multiple slot consumers render their children independently', () => { + it('multiple slot consumers hide their children independently when no Outlet is registered', () => { const SlotModule = slot(['a', 'b'] as const); render( @@ -58,8 +63,8 @@ describe('slot', () => { ); - expect(screen.getByText('slot a content')).toBeInTheDocument(); - expect(screen.getByText('slot b content')).toBeInTheDocument(); + expect(screen.queryByText('slot a content')).not.toBeInTheDocument(); + expect(screen.queryByText('slot b content')).not.toBeInTheDocument(); }); it('consumer throws when rendered outside provider', () => { diff --git a/static/app/components/core/slot/slot.tsx b/static/app/components/core/slot/slot.tsx index a405d3476a0a59..f21bc9bd3b0800 100644 --- a/static/app/components/core/slot/slot.tsx +++ b/static/app/components/core/slot/slot.tsx @@ -140,8 +140,6 @@ function makeSlotConsumer( return () => dispatch({type: 'decrement counter', name}); }, [dispatch, name]); - const element = state[name]?.element; - // Provide outletNameContext from the consumer so that portaled children // (which don't descend through the outlet in the component tree) can still // read which slot they belong to via useSlotOutletRef. @@ -151,11 +149,27 @@ function makeSlotConsumer( ); - if (!element) { - // Render in place as a fallback when no target element is registered yet - return wrappedChildren; + // A stable hidden container used as the portal target before an outlet + // mounts. Keeping children always inside a portal (rather than switching + // between portal and in-place rendering) means React never sees a + // different element type across renders, so component instances — and + // the ids they hold (e.g. useId()) — are preserved when the outlet mounts + // or unmounts. This also prevents a flash of content at the wrong DOM + // position that would occur with in-place rendering followed by a teleport. + const hiddenContainer = useRef(null); + if (!hiddenContainer.current && typeof document !== 'undefined') { + hiddenContainer.current = document.createElement('div'); + } + + const element = state[name]?.element; + const target = element ?? hiddenContainer.current; + + if (!target) { + // SSR: document is not available, render nothing. + return null; } - return createPortal(wrappedChildren, element); + + return createPortal(wrappedChildren, target); } SlotConsumer.displayName = 'Slot.Consumer'; diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 3affc33c3d4ca0..1bb7e7d03b6cf3 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -13,13 +13,25 @@ import { addLoadingMessage, clearIndicators, } from 'sentry/actionCreators/indicator'; +import {IconCellSignal} from 'sentry/components/badge/iconCellSignal'; +import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {Sticky} from 'sentry/components/sticky'; +import { + IconCheckmark, + IconIssues, + IconMerge, + IconMute, + IconSliders, + IconStack, +} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {GroupStore} from 'sentry/stores/groupStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {PageFilters} from 'sentry/types/core'; import type {Group} from 'sentry/types/group'; +import {GroupStatus, GroupSubstatus, PriorityLevel} from 'sentry/types/group'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; @@ -355,69 +367,117 @@ export function IssueListActions({ } return ( - - - {!allResultsVisible && pageSelected && ( - - - {allInQuerySelected ? ( - queryCount >= BULK_LIMIT ? ( - tct( - 'Selected up to the first [count] issues that match this search query.', - { - count: BULK_LIMIT_STR, - } + + + }}> + }} + onAction={() => { + toggleSelectAllVisible(); + setAllInQuerySelected(true); + }} + > + }} + onAction={() => + handleUpdate({status: GroupStatus.RESOLVED, statusDetails: {}}) + } + /> + }} + onAction={() => + handleUpdate({ + status: GroupStatus.IGNORED, + statusDetails: {}, + substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, + }) + } + /> + }}> + }} + onAction={() => handleUpdate({priority: PriorityLevel.HIGH})} + /> + }} + onAction={() => handleUpdate({priority: PriorityLevel.MEDIUM})} + /> + }} + onAction={() => handleUpdate({priority: PriorityLevel.LOW})} + /> + + }} + onAction={handleMerge} + /> + + + + + + {!allResultsVisible && pageSelected && ( + + + {allInQuerySelected ? ( + queryCount >= BULK_LIMIT ? ( + tct( + 'Selected up to the first [count] issues that match this search query.', + { + count: BULK_LIMIT_STR, + } + ) + ) : ( + tct('Selected all [count] issues that match this search query.', { + count: queryCount, + }) ) ) : ( - tct('Selected all [count] issues that match this search query.', { - count: queryCount, - }) - ) - ) : ( - - {tn( - '%s issue on this page selected.', - '%s issues on this page selected.', - numIssues - )} - - setAllInQuerySelected(true)}> - {queryCount >= BULK_LIMIT - ? tct( - 'Select the first [count] issues that match this search query.', - { - count: BULK_LIMIT_STR, - } - ) - : tct('Select all [count] issues that match this search query.', { - count: queryCount, - })} - - - )} - - - )} - + + {tn( + '%s issue on this page selected.', + '%s issues on this page selected.', + numIssues + )} + + setAllInQuerySelected(true)}> + {queryCount >= BULK_LIMIT + ? tct( + 'Select the first [count] issues that match this search query.', + { + count: BULK_LIMIT_STR, + } + ) + : tct('Select all [count] issues that match this search query.', { + count: queryCount, + })} + + + )} + + + )} + + ); } From 69f6d539fc444db0835c5e22c9a76ff451ab8326 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 06:56:11 +0200 Subject: [PATCH 2/7] fix(slot): Render nothing when no Outlet is registered Previously, SlotConsumer fell back to rendering children in-place when no outlet element was registered. This caused two problems: a flash of content at the wrong DOM position before the portal teleported it to the outlet, and a React subtree remount (in-place ReactNode vs ReactPortal are different element types) that reset useId() keys on every outlet mount/unmount cycle. Render null instead, so content only appears once the outlet is ready and component identity is preserved across outlet lifecycle changes. Co-Authored-By: Claude Sonnet 4.6 --- static/app/components/core/slot/slot.spec.tsx | 9 ++------- static/app/components/core/slot/slot.tsx | 18 ++---------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/static/app/components/core/slot/slot.spec.tsx b/static/app/components/core/slot/slot.spec.tsx index 942622e089bd2a..16b2a26123fb16 100644 --- a/static/app/components/core/slot/slot.spec.tsx +++ b/static/app/components/core/slot/slot.spec.tsx @@ -11,12 +11,7 @@ describe('slot', () => { expect(SlotModule.Fallback).toBeDefined(); }); - it('hides children in a detached container when no Outlet is registered', () => { - // Children are always portaled — to a stable hidden div before the outlet - // mounts, to the real outlet element once it registers. This prevents the - // in-place → portal type switch that would remount the subtree and lose - // component state (e.g. useId() keys). Content is intentionally invisible - // until an outlet is available. + it('renders nothing when no Outlet is registered', () => { const SlotModule = slot(['header'] as const); render( @@ -49,7 +44,7 @@ describe('slot', () => { ); }); - it('multiple slot consumers hide their children independently when no Outlet is registered', () => { + it('multiple slot consumers render nothing independently when no Outlet is registered', () => { const SlotModule = slot(['a', 'b'] as const); render( diff --git a/static/app/components/core/slot/slot.tsx b/static/app/components/core/slot/slot.tsx index f21bc9bd3b0800..62467617aa4758 100644 --- a/static/app/components/core/slot/slot.tsx +++ b/static/app/components/core/slot/slot.tsx @@ -149,27 +149,13 @@ function makeSlotConsumer( ); - // A stable hidden container used as the portal target before an outlet - // mounts. Keeping children always inside a portal (rather than switching - // between portal and in-place rendering) means React never sees a - // different element type across renders, so component instances — and - // the ids they hold (e.g. useId()) — are preserved when the outlet mounts - // or unmounts. This also prevents a flash of content at the wrong DOM - // position that would occur with in-place rendering followed by a teleport. - const hiddenContainer = useRef(null); - if (!hiddenContainer.current && typeof document !== 'undefined') { - hiddenContainer.current = document.createElement('div'); - } - const element = state[name]?.element; - const target = element ?? hiddenContainer.current; - if (!target) { - // SSR: document is not available, render nothing. + if (!element) { return null; } - return createPortal(wrappedChildren, target); + return createPortal(wrappedChildren, element); } SlotConsumer.displayName = 'Slot.Consumer'; From 002e36f8593053cb03b7df18d1c5ca9fdfd53641 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 06:56:19 +0200 Subject: [PATCH 3/7] fix(cmdk): Skip Select All action when all issues already selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If allInQuerySelected is already true, calling toggleSelectAllVisible() followed by setAllInQuerySelected(true) would deselect everything then immediately re-select it — two unnecessary state updates. Guard the action so it is a no-op when the full query is already selected. Co-Authored-By: Claude Sonnet 4.6 --- static/app/views/issueList/actions/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 1bb7e7d03b6cf3..58c85dda04bbf8 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -373,8 +373,10 @@ export function IssueListActions({ }} onAction={() => { - toggleSelectAllVisible(); - setAllInQuerySelected(true); + if (!allInQuerySelected) { + toggleSelectAllVisible(); + setAllInQuerySelected(true); + } }} > Date: Thu, 9 Apr 2026 07:31:51 +0200 Subject: [PATCH 4/7] feat(cmdk): Add Issue Feed actions to issues list page Register CMDK actions for the issues feed: select all + bulk actions (resolve, archive, set priority, merge) and individual issue actions for each visible issue in the list. Co-Authored-By: Claude --- .../commandPalette/__stories__/components.tsx | 7 +- .../app/components/commandPalette/ui/cmdk.tsx | 19 ++- .../commandPalette/ui/commandPalette.spec.tsx | 8 +- .../commandPalette/ui/commandPalette.tsx | 60 ++++++- .../components/commandPalette/ui/modal.tsx | 21 ++- .../useCommandPaletteActions.mdx | 7 +- static/app/views/issueList/actions/index.tsx | 158 +++++++++++++++++- 7 files changed, 258 insertions(+), 22 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index e7d81df6b38a6f..309d94a7b7afc9 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -6,6 +6,7 @@ import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; 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 type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -13,8 +14,12 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { + if (modifierKeys.shift) { + window.open(String(normalizeUrl(action.to)), '_blank', 'noopener,noreferrer'); + return; + } navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { action.onAction(); diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 5847fb97e04242..5a850c0562f77b 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -13,11 +13,22 @@ import { useCommandPaletteState, } from './commandPaletteStateContext'; -interface DisplayProps { - label: string; - details?: string; +type DisplayProps = { icon?: React.ReactNode; -} +} & ( + | { + label: string; + details?: string; + searchableDetails?: never; + searchableLabel?: never; + } + | { + label: Exclude; + searchableLabel: string; + details?: React.ReactNode; + searchableDetails?: string; + } +); interface CMDKActionDataBase { display: DisplayProps; diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index c0147e89d61147..4c108ab3815402 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -30,6 +30,7 @@ import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; 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 type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -84,7 +85,7 @@ function GlobalActionsComponent({ const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, _modifierKeys: CMDKModifierKeys) => { if ('to' in action) { navigate(action.to); } else if ('onAction' in action) { @@ -372,7 +373,10 @@ describe('CommandPalette', () => { // Mirror the updated modal.tsx handleSelect: invoke callback, skip close when // action has children so the palette can push into the secondary actions. - const handleAction = (action: CollectionTreeNode) => { + const handleAction = ( + action: CollectionTreeNode, + _modifierKeys: CMDKModifierKeys + ) => { if ('onAction' in action) { action.onAction(); if (action.children.length > 0) { diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index d4a8911b3b9c6a..161a12a22808c1 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -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'; @@ -64,8 +64,15 @@ type CMDKFlatItem = CollectionTreeNode & { listItemType: 'action' | 'section'; }; +export interface CMDKModifierKeys { + shift: boolean; +} + interface CommandPaletteProps { - onAction: (action: CollectionTreeNode) => void; + onAction: ( + action: CollectionTreeNode, + modifierKeys: CMDKModifierKeys + ) => void; children?: React.ReactNode; } @@ -116,12 +123,14 @@ export function CommandPalette(props: CommandPaletteProps) { children: actions.map(action => { const menuItem = makeMenuItemFromAction(action); + const textValue = getTextValue(action.display); + if (action.listItemType === 'section') { return ( {...menuItem} key={action.key} - textValue={action.display.label} + textValue={textValue} {...{ leadingItems: null, label: ( @@ -152,7 +161,7 @@ export function CommandPalette(props: CommandPaletteProps) { {...menuItem} key={action.key} - textValue={action.display.label} + textValue={textValue} > {menuItem.label} @@ -215,21 +224,45 @@ export function CommandPalette(props: CommandPaletteProps) { if ('onAction' in action) { // Invoke the callback but keep the modal open so users can select // secondary actions from the children that follow. - props.onAction(action); + props.onAction(action, {shift: false}); } dispatch({type: 'push action', key: action.key, label: action.display.label}); return; } + const modifierKeys: CMDKModifierKeys = {shift: shiftHeldRef.current}; + analytics.recordAction(action, resultIndex, ''); - dispatch({type: 'trigger action'}); - props.onAction(action); + // When Shift is held and the action is a navigation link, keep the palette + // open (no trigger action dispatch) so the user can continue selecting. + if (!modifierKeys.shift || !('to' in action)) { + dispatch({type: 'trigger action'}); + } + props.onAction(action, modifierKeys); }, [actions, analytics, dispatch, props] ); const resultsListRef = useRef(null); + // Track whether Shift is held so that actions with `to` can be opened in a + // new tab. Using a ref (instead of state) avoids re-renders on every keypress. + const shiftHeldRef = useRef(false); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftHeldRef.current = true; + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftHeldRef.current = false; + }; + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + }; + }, []); + const debouncedQuery = useDebouncedValue(state.query, 300); const isLoading = state.query.length > 0 && debouncedQuery !== state.query; @@ -417,8 +450,13 @@ function scoreNode( query: string, node: CollectionTreeNode ): {matched: boolean; score: number} { - const label = node.display.label; - const details = node.display.details ?? ''; + const display = node.display; + const label = + typeof display.label === 'string' ? display.label : display.searchableLabel; + const details = + typeof display.details === 'string' + ? display.details + : (display.searchableDetails ?? ''); const keywords = node.keywords ?? []; // Score each field independently and take the best result. This lets @@ -558,6 +596,10 @@ function flattenActions( }); } +function getTextValue(display: CMDKActionData['display']): string { + return typeof display.label === 'string' ? display.label : display.searchableLabel; +} + function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem { return { key: action.key, diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 11303e4982a394..7f5460e81dc0ac 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -1,21 +1,40 @@ import {useCallback} from 'react'; import {css} from '@emotion/react'; +import type {LocationDescriptorObject} from 'history'; 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 type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; +function locationToString(to: string | LocationDescriptorObject): string { + if (typeof to === 'string') { + return to; + } + return [to.pathname, to.search, to.hash].filter(Boolean).join(''); +} + export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { const navigate = useNavigate(); const handleSelect = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { + if (modifierKeys.shift) { + // Open in a new tab and leave the palette open so the user can + // continue selecting more items. + window.open( + locationToString(normalizeUrl(action.to)), + '_blank', + 'noopener,noreferrer' + ); + return; + } navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { action.onAction(); diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 5f5b11de3840a9..7a5d6ed0080fca 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -45,6 +45,7 @@ import { 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 type {CMDKModifierKeys} from 'sentry/components/commandPalette/ui/commandPalette'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -52,8 +53,12 @@ function YourComponent() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { + if (modifierKeys.shift) { + window.open(String(normalizeUrl(action.to)), '_blank', 'noopener,noreferrer'); + return; + } navigate(normalizeUrl(String(action.to))); } else if ('onAction' in action) { action.onAction(); diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 58c85dda04bbf8..76ba7130004928 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -4,8 +4,10 @@ import styled from '@emotion/styled'; import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-motion'; import {Alert} from '@sentry/scraps/alert'; +import {ProjectAvatar} from '@sentry/scraps/avatar'; import {Checkbox} from '@sentry/scraps/checkbox'; import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group'; import { @@ -16,6 +18,7 @@ import { import {IconCellSignal} from 'sentry/components/badge/iconCellSignal'; import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import {ErrorLevel} from 'sentry/components/events/errorLevel'; import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {Sticky} from 'sentry/components/sticky'; import { @@ -36,6 +39,7 @@ import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; import {useQueryClient} from 'sentry/utils/queryClient'; +import {capitalize} from 'sentry/utils/string/capitalize'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -284,6 +288,60 @@ export function IssueListActions({ return selection.projects; } + function handleUpdateForItems(itemIds: string[], data: IssueUpdateData) { + if ('status' in data && data.status === 'ignored') { + const statusDetails = + 'ignoreCount' in data.statusDetails + ? 'ignoreCount' + : 'ignoreDuration' in data.statusDetails + ? 'ignoreDuration' + : 'ignoreUserCount' in data.statusDetails + ? 'ignoreUserCount' + : undefined; + trackAnalytics('issues_stream.archived', { + action_status_details: statusDetails, + action_substatus: data.substatus, + organization, + }); + } + if ('priority' in data) { + trackAnalytics('issues_stream.updated_priority', { + organization, + priority: data.priority, + }); + } + addLoadingMessage(t('Saving changes\u2026')); + bulkUpdate( + api, + { + orgId: organization.slug, + itemIds, + data, + query, + environment: selection.environments, + failSilently: true, + project: getSelectedProjectIds(itemIds), + ...selection.datetime, + }, + { + success: () => { + clearIndicators(); + onActionTaken?.(itemIds, data); + for (const itemId of itemIds) { + queryClient.invalidateQueries({ + queryKey: [`/organizations/${organization.slug}/issues/${itemId}/`], + exact: false, + }); + } + }, + error: () => { + clearIndicators(); + addErrorMessage(t('Unable to update issues')); + }, + } + ); + } + function handleUpdate(data: IssueUpdateData) { if ('status' in data && data.status === 'ignored') { const statusDetails = @@ -409,11 +467,103 @@ export function IssueListActions({ onAction={() => handleUpdate({priority: PriorityLevel.LOW})} /> - }} - onAction={handleMerge} - /> + {groupIds.length > 1 && ( + }} + onAction={handleMerge} + /> + )} + {groupIds.map(id => { + const group = GroupStore.get(id); + if (!group) return null; + + const errorType = group.metadata.type; + const errorValue = group.metadata.value; + const labelText = errorType + ? `${errorType}: ${errorValue ?? ''}` + : group.title; + const detailsText = [group.project.slug, group.level, group.assignedTo?.name] + .filter(Boolean) + .join(' '); + + return ( + , + label: errorType ? ( + + + {errorType} + + {errorValue ? `: ${errorValue}` : null} + + ) : ( + {group.title} + ), + searchableLabel: labelText, + details: ( + + + + {group.project.slug} + + + {capitalize(group.level)} + + {group.assignedTo && ( + + {group.assignedTo.name} + + )} + + ), + searchableDetails: detailsText, + }} + > + }} + onAction={() => + handleUpdateForItems([id], { + status: GroupStatus.RESOLVED, + statusDetails: {}, + }) + } + /> + }} + onAction={() => + handleUpdateForItems([id], { + status: GroupStatus.IGNORED, + statusDetails: {}, + substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, + }) + } + /> + }}> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.HIGH}) + } + /> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.MEDIUM}) + } + /> + }} + onAction={() => + handleUpdateForItems([id], {priority: PriorityLevel.LOW}) + } + /> + + + ); + })} From e737c61b39236e3523e0880e5f226872dc4a1f32 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 07:55:34 +0200 Subject: [PATCH 5/7] ref(cmdk): Improve issue item display in command palette Move ErrorLevel indicator into the label alongside the title, drop the separate level text from details, shrink title to sm and details to xs, add paddingBottom to the label for breathing room, and use getTextValue when pushing nav-stack entries so rich ReactNode labels produce correct placeholder text. Co-Authored-By: Claude --- .../commandPalette/__stories__/components.tsx | 2 +- .../commandPalette/ui/commandPalette.tsx | 9 ++++- .../components/commandPalette/ui/modal.tsx | 6 +-- .../useCommandPaletteActions.mdx | 2 +- static/app/views/issueList/actions/index.tsx | 38 ++++++++++--------- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 309d94a7b7afc9..8cfbb59b8b2bd7 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -17,7 +17,7 @@ export function CommandPaletteDemo() { (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { if (modifierKeys.shift) { - window.open(String(normalizeUrl(action.to)), '_blank', 'noopener,noreferrer'); + window.open(String(normalizeUrl(action.to)), '_blank'); return; } navigate(normalizeUrl(action.to)); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 161a12a22808c1..2e809509167838 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -30,7 +30,7 @@ import { 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, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; import {fzf} from 'sentry/utils/search/fzf'; @@ -226,7 +226,11 @@ export function CommandPalette(props: CommandPaletteProps) { // secondary actions from the children that follow. props.onAction(action, {shift: false}); } - dispatch({type: 'push action', key: action.key, label: action.display.label}); + dispatch({ + type: 'push action', + key: action.key, + label: getTextValue(action.display), + }); return; } @@ -618,6 +622,7 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI {action.display.icon} ), + trailingItems: 'to' in action ? : undefined, children: [], hideCheck: true, }; diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 7f5460e81dc0ac..4045c22d297000 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -28,11 +28,7 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps if (modifierKeys.shift) { // Open in a new tab and leave the palette open so the user can // continue selecting more items. - window.open( - locationToString(normalizeUrl(action.to)), - '_blank', - 'noopener,noreferrer' - ); + window.open(locationToString(normalizeUrl(action.to)), '_blank'); return; } navigate(normalizeUrl(action.to)); diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 7a5d6ed0080fca..96199445aea481 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -56,7 +56,7 @@ function YourComponent() { (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { if (modifierKeys.shift) { - window.open(String(normalizeUrl(action.to)), '_blank', 'noopener,noreferrer'); + window.open(String(normalizeUrl(action.to)), '_blank'); return; } navigate(normalizeUrl(String(action.to))); diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 76ba7130004928..3d080455189322 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -6,7 +6,7 @@ import {AnimatePresence, motion, type MotionNodeAnimationOptions} from 'framer-m import {Alert} from '@sentry/scraps/alert'; import {ProjectAvatar} from '@sentry/scraps/avatar'; import {Checkbox} from '@sentry/scraps/checkbox'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group'; @@ -39,7 +39,6 @@ import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; import {useQueryClient} from 'sentry/utils/queryClient'; -import {capitalize} from 'sentry/utils/string/capitalize'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -491,29 +490,34 @@ export function IssueListActions({ , - label: errorType ? ( - - - {errorType} - - {errorValue ? `: ${errorValue}` : null} - - ) : ( - {group.title} + label: ( + + + + + {errorType ? ( + + + {errorType} + + {errorValue ? `: ${errorValue}` : null} + + ) : ( + group.title + )} + + + ), searchableLabel: labelText, details: ( - + {group.project.slug} - - {capitalize(group.level)} - {group.assignedTo && ( - + {group.assignedTo.name} )} From 6e5e899d02f25cec22bf819e1487435f4572e3ae Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 08:28:42 +0200 Subject: [PATCH 6/7] ref(issues) cmdk actions --- .../commandPalette/ui/commandPalette.tsx | 21 ++++++--- .../ui/commandPaletteGlobalActions.tsx | 25 +++-------- .../components/commandPalette/ui/modal.tsx | 9 +++- static/app/views/issueList/actions/index.tsx | 45 ++++++++++++++++++- 4 files changed, 73 insertions(+), 27 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 2e809509167838..9e2286c87c4ee4 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -30,7 +30,7 @@ import { import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {IconArrow, IconClose, IconLink, 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'; @@ -249,6 +249,12 @@ export function CommandPalette(props: CommandPaletteProps) { const resultsListRef = useRef(null); + useEffect(() => { + if (resultsListRef.current) { + resultsListRef.current.scrollTop = 0; + } + }, [state.action, state.query]); + // Track whether Shift is held so that actions with `to` can be opened in a // new tab. Using a ref (instead of state) avoids re-renders on every keypress. const shiftHeldRef = useRef(false); @@ -331,9 +337,6 @@ export function CommandPalette(props: CommandPaletteProps) { onChange: (e: React.ChangeEvent) => { dispatch({type: 'set query', query: e.target.value}); treeState.selectionManager.setFocusedKey(null); - if (resultsListRef.current) { - resultsListRef.current.scrollTop = 0; - } }, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Backspace' && state.query.length === 0) { @@ -622,7 +625,15 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI {action.display.icon} ), - trailingItems: 'to' in action ? : undefined, + trailingItems: + 'to' in action ? ( + typeof action.to === 'string' && + (action.to.startsWith('http://') || action.to.startsWith('https://')) ? ( + + ) : ( + + ) + ) : undefined, children: [], hideCheck: true, }; diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index a3cae75f2fc0f8..34e2f66c9166a1 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -4,7 +4,6 @@ import DOMPurify from 'dompurify'; import {ProjectAvatar} from '@sentry/scraps/avatar'; import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; import {openSudo} from 'sentry/actionCreators/sudoModal'; import type { CMDKQueryOptions, @@ -30,7 +29,6 @@ import { IconSearch, IconSettings, IconStar, - IconUser, } from 'sentry/icons'; import {t} from 'sentry/locale'; import {apiOptions} from 'sentry/utils/api/apiOptions'; @@ -276,11 +274,6 @@ export function GlobalCommandPaletteActions() { keywords={[t('add project')]} to={`${prefix}/projects/new/`} /> - }} - keywords={[t('team invite')]} - onAction={openInviteMembersModal} - /> @@ -343,25 +336,19 @@ export function GlobalCommandPaletteActions() { }} - onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + to="https://docs.sentry.io" /> }} - onAction={() => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer') - } + to="https://discord.gg/sentry" /> }} - onAction={() => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') - } + to="https://github.com/getsentry/sentry" /> }} - onAction={() => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') - } + display={{label: t('View Changelog')}} + to="https://sentry.io/changelog/" /> - }}> + { diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 4045c22d297000..c0ce6ea690b006 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -25,13 +25,18 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps const handleSelect = useCallback( (action: CollectionTreeNode, modifierKeys: CMDKModifierKeys) => { if ('to' in action) { - if (modifierKeys.shift) { + const href = locationToString(action.to); + const isExternal = href.startsWith('http://') || href.startsWith('https://'); + if (isExternal) { + window.open(href, '_blank', 'noreferrer'); + } else if (modifierKeys.shift) { // Open in a new tab and leave the palette open so the user can // continue selecting more items. window.open(locationToString(normalizeUrl(action.to)), '_blank'); return; + } else { + navigate(normalizeUrl(action.to)); } - navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { action.onAction(); // When the action has children, the palette will push into them so the diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 3d080455189322..5bf761e1070699 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -27,6 +27,7 @@ import { IconMerge, IconMute, IconSliders, + IconSort, IconStack, } from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; @@ -39,8 +40,11 @@ import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; import {useQueryClient} from 'sentry/utils/queryClient'; +import {decodeScalar} from 'sentry/utils/queryString'; import {useApi} from 'sentry/utils/useApi'; +import {useLocation} from 'sentry/utils/useLocation'; import {useMedia} from 'sentry/utils/useMedia'; +import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; import { @@ -48,7 +52,13 @@ import { useIssueSelectionSummary, } from 'sentry/views/issueList/issueSelectionContext'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; -import {SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY} from 'sentry/views/issueList/utils'; +import { + DEFAULT_ISSUE_STREAM_SORT, + FOR_REVIEW_QUERIES, + getSortLabel, + IssueSortOptions, + SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY, +} from 'sentry/views/issueList/utils'; import {ActionSet} from './actionSet'; import {Headers} from './headers'; @@ -182,6 +192,12 @@ export function IssueListActions({ const api = useApi(); const queryClient = useQueryClient(); const organization = useOrganization(); + const location = useLocation(); + const navigate = useNavigate(); + const sort = decodeScalar( + location.query.sort, + DEFAULT_ISSUE_STREAM_SORT + ) as IssueSortOptions; const {setAllInQuerySelected, deselectAll, toggleSelectAllVisible} = useIssueSelectionActions(); const {pageSelected, multiSelected, anySelected, allInQuerySelected, selectedIdsSet} = @@ -473,6 +489,33 @@ export function IssueListActions({ /> )} + }}> + {[ + ...(FOR_REVIEW_QUERIES.includes(query || '') + ? [IssueSortOptions.INBOX] + : []), + IssueSortOptions.DATE, + IssueSortOptions.NEW, + IssueSortOptions.TRENDS, + IssueSortOptions.FREQ, + IssueSortOptions.USER, + ].map(sortOption => ( + : undefined, + }} + onAction={() => { + trackAnalytics('issues_stream.sort_changed', { + organization, + sort: sortOption, + }); + navigate({...location, query: {...location.query, sort: sortOption}}); + }} + /> + ))} + {groupIds.map(id => { const group = GroupStore.get(id); if (!group) return null; From df515b23f79643920fa427ddc3a07abbdb039f60 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 10:54:34 +0200 Subject: [PATCH 7/7] ref(issues) add initial issues cmdk actions --- .../app/components/commandPalette/ui/cmdk.tsx | 12 ++- .../commandPalette/ui/collection.tsx | 1 + .../commandPalette/ui/commandPalette.tsx | 11 +-- .../ui/commandPaletteGlobalActions.tsx | 82 +++++++++---------- static/app/views/issueList/actions/index.tsx | 35 +++++--- static/app/views/issueList/groupListBody.tsx | 2 +- 6 files changed, 81 insertions(+), 62 deletions(-) diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 5a850c0562f77b..5572c271762dc2 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -33,6 +33,8 @@ type DisplayProps = { interface CMDKActionDataBase { display: DisplayProps; keywords?: string[]; + /** Maximum number of children to show in browse and search mode. */ + limit?: number; ref?: React.RefObject; } @@ -77,6 +79,8 @@ interface CMDKActionProps { display: DisplayProps; children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode); keywords?: string[]; + /** Maximum number of children to show in browse and search mode. */ + limit?: number; onAction?: () => void; resource?: (query: string) => CMDKQueryOptions; to?: LocationDescriptor; @@ -91,6 +95,7 @@ interface CMDKActionProps { export function CMDKAction({ display, keywords, + limit, children, to, onAction, @@ -101,11 +106,12 @@ export function CMDKAction({ const nodeData: CMDKActionData = to === undefined ? onAction === undefined - ? {display, keywords, ref, resource} - : {display, keywords, ref, onAction} - : {display, keywords, ref, to}; + ? {display, keywords, limit, ref, resource} + : {display, keywords, limit, ref, onAction} + : {display, keywords, limit, ref, to}; const key = CMDKCollection.useRegisterNode(nodeData); + const {query} = useCommandPaletteState(); const resourceOptions = resource diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index a16b3d0fd1b9ca..2f42fffc8b292e 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -164,6 +164,7 @@ export function makeCollection(): CollectionInstance { return () => { store.unregister(key); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, parentKey, store]); return key; diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 9e2286c87c4ee4..57f86ef1aa2f14 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -504,11 +504,6 @@ function scoreTree( } } -// Maximum number of children to show per group, in both browse and search mode. -// Prevents groups with many items (e.g. per-project settings on large orgs) -// from flooding the results list. -const MAX_GROUP_CHILDREN = 4; - function flattenActions( nodes: Array>, scores: Map< @@ -536,7 +531,9 @@ function flattenActions( // that nested groups (e.g. "Set Priority" inside "Select All") are // shown as navigable actions rather than pre-expanded sections. results.push({...node, listItemType: 'section'}); - for (const child of node.children.slice(0, MAX_GROUP_CHILDREN)) { + for (const child of node.limit === undefined + ? node.children + : node.children.slice(0, node.limit)) { results.push({...child, listItemType: 'action'}); } } else { @@ -588,7 +585,7 @@ function flattenActions( (scores.get(b.key)?.score.score ?? 0) - (scores.get(a.key)?.score.score ?? 0) ) - .slice(0, MAX_GROUP_CHILDREN) + .slice(0, item.limit) .map(c => ({...c, listItemType: 'action' as const})), ]; } diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 34e2f66c9166a1..c8244f218eb583 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -106,13 +106,15 @@ export function GlobalCommandPaletteActions() { to={`${prefix}/issues/feedback/`} /> - {starredViews.map(starredView => ( - }} - to={`${prefix}/issues/views/${starredView.id}/`} - /> - ))} + }}> + {starredViews.map(starredView => ( + + ))} + }}> @@ -206,7 +208,10 @@ export function GlobalCommandPaletteActions() { )} - }}> + }} + limit={10} + > {projects.map(project => ( ))} + - {user.isStaff && ( - + {user.isStaff && ( + + }} + keywords={[t('superuser')]} + to="/_admin/" + /> + , + }} + keywords={[t('superuser')]} + to={`/_admin/customers/${organization.slug}/`} + /> + {!isActiveSuperuser() && ( }} + display={{label: t('Open Superuser Modal'), icon: }} keywords={[t('superuser')]} - onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} + onAction={() => openSudo({isSuperuser: true, needsReload: true})} /> + )} + {isActiveSuperuser() && ( , - }} + display={{label: t('Exit Superuser'), icon: }} keywords={[t('superuser')]} - onAction={() => - window.open( - `/_admin/customers/${organization.slug}/`, - '_blank', - 'noreferrer' - ) - } + onAction={() => exitSuperuser()} /> - {!isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => openSudo({isSuperuser: true, needsReload: true})} - /> - )} - {isActiveSuperuser() && ( - }} - keywords={[t('superuser')]} - onAction={() => exitSuperuser()} - /> - )} - - )} - + )} + + )} }} keywords={[t('client keys'), t('dsn keys')]} + limit={10} > {projects.map(project => ( - + - - }}> + + }} limit={6}> }} onAction={() => { @@ -516,6 +517,8 @@ export function IssueListActions({ /> ))} + + }} limit={6}> {groupIds.map(id => { const group = GroupStore.get(id); if (!group) return null; @@ -525,7 +528,11 @@ export function IssueListActions({ const labelText = errorType ? `${errorType}: ${errorValue ?? ''}` : group.title; - const detailsText = [group.project.slug, group.level, group.assignedTo?.name] + const detailsText = [ + group.project.slug, + group.assignedTo ? `assigned to ${group.assignedTo.name}` : 'unassigned', + group.substatus, + ] .filter(Boolean) .join(' '); @@ -556,14 +563,22 @@ export function IssueListActions({ details: ( - - {group.project.slug} + + {group.project.slug},{' '} + {group.assignedTo + ? tct('assigned to: [name]', {name: group.assignedTo.name}) + : t('Unassigned')} + {', '} + + {(group.substatus === GroupSubstatus.ESCALATING || + group.substatus === GroupSubstatus.ONGOING || + group.substatus === GroupSubstatus.REGRESSED) && + `, ${group.substatus}`} - {group.assignedTo && ( - - {group.assignedTo.name} - - )} ), searchableDetails: detailsText, diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index fad13644e0ff3b..cb7d48cadff080 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -6,7 +6,7 @@ import type {GroupListColumn} from 'sentry/components/issues/groupList'; import {LoadingError} from 'sentry/components/loadingError'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; -import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; +import {SupergroupRow} from 'sentry/components/stream/supergroups/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; import {useApi} from 'sentry/utils/useApi';