--- name: cmdk-actions description: Guide for adding new actions to Sentry's Command+K palette. Use when implementing new cmdk actions, registering page-level or global actions, building async resource pickers, or adding contextual actions to a view. --- # Adding Actions to the Command Palette (cmdk) Sentry's Command+K palette is built on a tree-collection system where `CMDKAction` components register themselves via React context. Actions render wherever in the component tree they live — no central registry to update. ## Core files - **`static/app/components/commandPalette/ui/cmdk.tsx`** — `CMDKAction` component (the only primitive you need) - **`static/app/components/commandPalette/types.tsx`** — public types + `cmdkQueryOptions` helper - **`static/app/components/commandPalette/ui/commandPaletteSlot.tsx`** — `CommandPaletteSlot` for scoping - **`static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx`** — always-on global actions --- ## The Three Slots Slots control sort order and lifetime. Import from `commandPaletteSlot.tsx`: ```tsx import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; ``` | Slot | Order in palette | Lifetime | Use for | | -------- | ------------------------ | ------------------------------------------ | ----------------------------------------------------------------------- | | `task` | First (highest priority) | Reserved — not yet used in production | Future transient workflow steps | | `page` | Second | Tied to the page component's mount/unmount | Contextual actions for the current view (issue details, settings pages) | | `global` | Last | Always present for any org | Org-wide navigation, create actions, help | Wrap page-level actions in the slot provider: ```tsx // In a page component or its sub-tree ``` Global actions are registered once in `GlobalCommandPaletteActions` — add to that component rather than creating a new global slot consumer. --- ## `CMDKAction` Props ```ts interface CMDKActionProps { // Required: what the user sees display: { label: string; // primary text details?: string; // secondary description line icon?: React.ReactNode; // icon on the left — use default size for section icons, // size={16} for avatars (ProjectAvatar, ActorAvatar, TeamAvatar) trailingItem?: React.ReactNode; // right-side decoration (overrides link indicator) }; // Optional: improve search recall keywords?: string[]; // Optional stable key. Prefix with "cmdk:supplementary:" to sort last in // search results regardless of fuzzy score (used for the Help section). id?: string; // --- Choose one action type (TypeScript union enforces mutual exclusivity) --- // 1. Navigate to?: LocationDescriptor; // 2. Callback onAction?: () => void; // 3. Group/resource — requires children or resource to render anything. // Without at least one of those the component returns null. resource?: (query: string, context: CMDKResourceContext) => CMDKQueryOptions; children?: React.ReactNode | ((data: CommandPaletteAction[]) => React.ReactNode); // --- Group display --- // Overrides the input placeholder when the user drills into this action. // Has no effect without children or resource — the node still needs content // to drill into. prompt?: string; // Max results shown before a "See all" expansion item appears. // Default: 4 when resource is set and children is a render-prop function. // No default for static children. limit?: number; } ``` --- ## Action Patterns ### 1. Navigation link ```tsx import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import {IconIssues} from 'sentry/icons'; , }} keywords={['bugs', 'errors', 'problems']} to={`/organizations/${org.slug}/issues/`} />; ``` ### 2. Callback action ```tsx handleResolve(group.id)} /> ``` ### 3. Static group Nest `CMDKAction` children to create a drillable group. The parent label appears as a breadcrumb prefix in search results (e.g. `Set Priority > High`), so use a label that identifies the context. **Group icon as current-state indicator**: set the group's own icon to reflect the current value so the user can see the state before drilling in. Both the priority and assignee selectors do this: ```tsx // Icon reflects current priority — user sees state at a glance , }} > }} onAction={() => setPriority('high')} /> }} onAction={() => setPriority('medium')} /> }} onAction={() => setPriority('low')} /> ; // Icon reflects current assignee — avatar when assigned, generic icon when not const assigneeIcon = group.assignedTo ? ( ) : ( ); {/* children */} ; ``` ### 4. Async resource picker Use `resource` + `cmdkQueryOptions` to load items from an API. The user types to filter. The loading spinner activates automatically while the query is in flight. Note: when the user drills into a resource node the palette clears the query. Your `resource` function will initially receive an empty string — design your query params accordingly. ```tsx import {cmdkQueryOptions} from 'sentry/components/commandPalette/types'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {ProjectAvatar} from '@sentry/scraps/avatar'; cmdkQueryOptions({ ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { path: {organizationIdOrSlug: org.slug}, query: {query, per_page: 20}, staleTime: 30_000, }), // Only fetch once the user has drilled into this node enabled: context.state === 'selected', select: projects => projects.map(project => ({ display: { label: project.slug, icon: , }, to: `/organizations/${org.slug}/projects/${project.slug}/`, })), }) } />; ``` **Rules for `resource`:** - **Always** wrap with `cmdkQueryOptions(...)` — this injects `meta: { cmdk: true }` so the palette's loading spinner tracks the request via `useIsFetching`. - Use `enabled: context.state === 'selected'` to defer fetching until the user actually drills in. - The `select` field must transform the API response into `CommandPaletteAction[]`. - `query` is the live search input value (not debounced) — pass it through as a search param. - Use `staleTime: Infinity` for data that rarely changes (project lists, settings nav items). Use `staleTime: 30_000` for user/session data. ### 5. Resource with render-prop children Use a render-prop when you need custom rendering or want to mix static and async items. `CommandPaletteAction` is a union that includes groups (which have `actions`, not `children`). Don't blindly spread items into `CMDKAction` — type-narrow to only handle `to` and `onAction` variants, as the codebase's own `renderAsyncResult` helper does: ```tsx // Type-safe helper — skip groups, which can't be spread into CMDKAction function renderAsyncResult(item: CommandPaletteAction, index: number) { if ('to' in item) return ; if ('onAction' in item) return ; return null; } cmdkQueryOptions({ queryKey: ['members', org.slug, query], queryFn: () => fetchMembers(org.slug, query), enabled: context.state === 'selected', select: members => members.map(m => ({ display: {label: m.name, details: m.email}, onAction: () => assignTo(m.id), })), }) } > {members => ( <> {/* Static first entry */} {/* Dynamic entries from resource */} {members.map(renderAsyncResult)} )} ; ``` **Auto-render limitation**: when `children` is _not_ a render-prop (static children + `resource`), resource results that are `CommandPaletteActionGroup` items are silently skipped. Only `to` and `onAction` results are auto-rendered. Use the render-prop pattern if you need groups from a resource. ### 6. Static async children via hook When a dataset is small and already cached, fetch it with a hook and render it as static JSX children. The palette's built-in fuzzy search handles filtering client-side — no `resource` prop needed: ```tsx // useProjectMembers fetches once and caches; palette fuzzy-searches the results const {data: members = []} = useProjectMembers(project.id); const assignableUsers = members.filter(m => m.id !== currentUser.id); handleAssign(currentUser)} /> {assignableUsers.map(member => ( ), }} onAction={() => handleAssign(member)} /> ))} {teams.map(team => ( , }} onAction={() => handleAssign(team)} /> ))} ; ``` **When to use static children vs `resource`:** | | Static children via hook | `resource` prop | | ------------- | -------------------------- | ----------------------- | | Dataset size | Small, bounded | Large or unbounded | | Filtering | Client-side fuzzy search | Server-side search | | Fetch timing | Eager (on component mount) | Deferred (on drill-in) | | Query updates | Fixed at render | Responds to typed query | **Key naming for mixed entity lists**: prefix keys with the entity type to prevent collisions — `member-${id}`, `team-${id}`, `${owner.type}-${owner.id}`, `coding-agent:${id}`. ### 7. `trailingItem` — marking the active item Use `trailingItem` with a `"Current"` badge only for **entity selections** where the user is switching between distinct objects — projects, organizations, users, environments, and similar. The badge answers the question "which one am I on right now?" when the items are otherwise indistinguishable. **Do not** use `"Current"` for settings or modes (sort order, theme, display density, etc.). Those have a single correct value at any time, and the group's own label or icon already reflects it (see the group-icon-as-state-indicator pattern above). A `"Current"` badge on a settings option duplicates information without adding clarity. ```tsx import {Tag} from '@sentry/scraps/badge'; // ✅ Entity selection — badge makes sense: user sees which project they're on cmdkQueryOptions({...})} > {/* Current project rendered statically so it always appears first */} , trailingItem: {t('Current')}, }} to={`/organizations/${org.slug}/projects/${currentProject.slug}/`} /> // ❌ Settings/mode — do not use a badge; the group label already shows the active value , }} > {sortKeys.map(key => ( {t('Current')} : undefined // ❌ Don't do this — the group label already communicates the active sort }} onAction={() => onSortChange(key)} /> ))} ``` ### 7. Query-content-gated resource A resource can activate based on what the user has typed, not just drill-in state. Use this for contextual lookup tools that only make sense for a specific query shape: ```tsx const DSN_PATTERN = /^https?:\/\/.+@.+\/.+/; cmdkQueryOptions({ ...apiOptions.as()(/* ... */), // Only fetch when the query looks like a DSN enabled: context.state === 'selected' && DSN_PATTERN.test(query), select: result => result.navTargets.map(/* ... */), }) } />; ``` ### 8. State-conditional actions Render different actions based on current entity state — not just feature flags. Actions that don't apply to the current state should simply not render: ```tsx }> {!isResolved && !isArchived && ( )} {!isResolved && !isArchived && ( )} {isResolved && ( )} {isArchived && ( )} ``` ### 9. Supplementary (always-last) section Prefix `id` with `cmdk:supplementary:` to sort the section after all other results, regardless of search score. Reserved for content like Help links that should never surface above real actions. ```tsx ``` --- ## Splitting Actions Across Components When a page's action set is complex, split it across multiple components. Child components that register actions **do not need their own slot** — they inherit the slot context from the parent that established it. Just emit `` nodes directly: ```tsx // views/issueDetails/groupPriorityActions.tsx // No slot here — registers under whatever parent mounts this function GroupPriorityActions({group}: {group: Group}) { return ( setPriority('high')} /> setPriority('medium')} /> setPriority('low')} /> ); } // views/issueDetails/seerActions.tsx // Returns a Fragment of siblings — adds actions into the parent group without // creating an extra nesting level function SeerActions({group}: {group: Group}) { if (!canShowSeer) return null; return ( }} onAction={startAutofix} /> ); } // views/issueDetails/issueCommandPaletteActions.tsx // Only this component owns the slot function IssueCommandPaletteActions({group, issue}: Props) { return ( , }} > ); } ``` Use `` (not a wrapping ``) when a child component contributes flat siblings into an existing parent group. --- ## Registering Global Actions Add to `GlobalCommandPaletteActions` in `commandPaletteGlobalActions.tsx`. Don't create a second `global` slot consumer — there is one slot outlet in the navigation shell, so a second consumer would compete with it rather than extend it. It's a JSX component — just insert a new `CMDKAction` in the relevant group or create a new named group: ```tsx // Inside GlobalCommandPaletteActions render: {/* existing actions... */} }} to={`/organizations/${organization.slug}/crons/`} /> ``` --- ## Registering Page-Level Actions Create a component that wraps actions in `` and mount it inside the relevant page component. The actions register and deregister automatically with the page's mount/unmount lifecycle. ```tsx // views/myFeature/myFeatureCommandPaletteActions.tsx function MyFeatureCommandPaletteActions({item}: {item: MyItem}) { return ( archiveItem(item.id)} /> ); } // views/myFeature/myFeaturePage.tsx function MyFeaturePage() { return (
{/* rest of page */}
); } ``` --- ## Feature Flag and Permission Gates Gate on additional flags or permissions inline: ```tsx { organization.features.includes('my-feature') && ( ); } { user.isStaff && ; } ``` **Gate the entire slot when a page is disabled** — don't render individual disabled actions; don't render the slot at all: ```tsx // ✅ Gate at the slot level { !disabled && ( {/* all actions */} ); } ``` --- ## Capability Config When an entity type determines which actions are available, derive that from a config object rather than inline conditionals. `getConfigForIssueType(group, project)` returns per-action capability flags: ```tsx const config = useMemo(() => getConfigForIssueType(group, project), [group, project]); const { actions: {resolve: resolveCap, delete: deleteCap}, } = config; // Only render actions the issue type supports { resolveCap.enabled && ( ); } ``` For new entity types, follow the same pattern: define a config shape that carries capability flags, then gate rendering on those flags rather than scattered `group.type === '...'` checks. --- ## Workflow / Sequential State Machine When actions represent steps in a multi-stage workflow, show only the _next valid action_ — not all possible steps at once. Gate each step on the previous step being complete and the next not yet started: ```tsx // Extract state into a dedicated hook in the same file function useSeerState(group: Group, project: Project) { const autofix = useExplorerAutofix(group.id); const sections = getOrderedAutofixSections(autofix.runState); return { autofix, completedRootCause: sections.some( s => isRootCauseSection(s) && s.status === 'completed' ), completedSolution: sections.some( s => isSolutionSection(s) && s.status === 'completed' ), completedCodeChanges: sections.some( s => isCodeChangesSection(s) && s.status === 'completed' ), hasPR: sections.some(isPullRequestsSection), runId: autofix.runState?.run_id, isPolling: autofix.isPolling, }; } function WorkflowActions({group, project}: Props) { const { autofix, completedRootCause, completedSolution, completedCodeChanges, hasPR, runId, isPolling, } = useSeerState(group, project); // Guard: can only advance the workflow when not mid-operation and run exists const canContinue = !isPolling && defined(runId); return ( {(!autofix.runState || autofix.runState.status === 'error') && ( )} {canContinue && completedRootCause && !completedSolution && ( nextStep('solution', runId)} /> )} {canContinue && completedSolution && !completedCodeChanges && ( nextStep('code_changes', runId)} /> )} {canContinue && completedCodeChanges && !hasPR && ( createPR(runId)} /> )} ); } ``` Key points: - Extract the state logic into a dedicated `use*State` hook within the action component file — keeps the JSX clean. - Use a `canContinue` guard to prevent showing progress actions while an async operation is in flight. - Return `null` early at the top of the component when the feature isn't applicable: ```tsx // Guard clause — return null before any hooks if possible, else after if (!aiConfig.areAiFeaturesAllowed || !isExplorer || !issueTypeSupportsSeer || !event) { return null; } ``` --- ## Dynamic Labels Embed the current value in an action label to give context without requiring the user to drill in first: ```tsx // Shows who is currently assigned handleAssigneeChange(null)} /> // Shows the current value being changed ``` Use `t('... %s', value)` (printf-style) rather than template literals so strings remain translatable. --- ## Checklist - [ ] Wrap page-level actions in ``; add global actions to `GlobalCommandPaletteActions` - [ ] Child components that split a page action set do **not** add their own slot — they inherit from the parent - [ ] All `resource` functions use `cmdkQueryOptions(...)` - [ ] `resource` functions set `enabled: context.state === 'selected'` to defer fetching (or a query-content check for contextual resources) - [ ] `select` in resource options returns `CommandPaletteAction[]` - [ ] `prompt` is set on any drill-target that replaces the search placeholder - [ ] `limit` is set on resource nodes to avoid overwhelming the list (default 4 only applies when `resource` AND `children` is a render-prop function; auto-render mode has no default) - [ ] `staleTime: Infinity` for stable lists (projects, nav items); `staleTime: 30_000` for dynamic data - [ ] `id="cmdk:supplementary:..."` on any section that should always sort last - [ ] `keywords` added for non-obvious actions to improve search recall - [ ] Section/group icons use default size; avatar icons (`ProjectAvatar`, `ActorAvatar`, `TeamAvatar`) use `size={16}` - [ ] State-conditional actions (resolved, archived, etc.) are rendered conditionally rather than disabled - [ ] `disabled` state gates the entire ``, not individual actions - [ ] Group icon reflects the current value of the setting it controls (priority, assignee, theme) - [ ] Dynamic action labels use `t('... %s', value)` not template literals, so strings stay translatable - [ ] Workflow action components extract state logic into a dedicated `use*State` hook and use a `canContinue` guard - [ ] Components that are not applicable return `null` early via a guard clause before rendering any JSX - [ ] Entity capability config (e.g. `getConfigForIssueType`) drives action availability rather than scattered type checks - [ ] Dynamic list keys use `type-id` format (`member-${id}`, `team-${id}`) to prevent cross-type collisions