- 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
<CommandPaletteSlot name="page">
<CMDKAction display={{label: t('Resolve Issue')}} onAction={handleResolve} />
</CommandPaletteSlot>
```
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/issues';
<CMDKAction
display={{
label: t('Go to Issues'),
icon: <IconIssues />,
}}
keywords={['bugs', 'errors', 'problems']}
to={`/organizations/${org.slug}/issues/`}
/>;
```
### 2. Callback action
```tsx
<CMDKAction
display={{label: t('Resolve Issue'), details: t('Mark as resolved')}}
onAction={() => 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
<CMDKAction
display={{
label: t('Set Priority'),
icon: <IconCellSignal bars={PRIORITY_BARS[group.priority ?? PriorityLevel.MEDIUM]} />,
}}
>
<CMDKAction
display={{label: t('High'), icon: <IconCellSignal bars={3} />}}
onAction={() => setPriority('high')}
/>
<CMDKAction
display={{label: t('Medium'), icon: <IconCellSignal bars={2} />}}
onAction={() => setPriority('medium')}
/>
<CMDKAction
display={{label: t('Low'), icon: <IconCellSignal bars={1} />}}
onAction={() => setPriority('low')}
/>
</CMDKAction>;
// Icon reflects current assignee — avatar when assigned, generic icon when not
const assigneeIcon = group.assignedTo ? (
<ActorAvatar actor={group.assignedTo} size={16} hasTooltip={false} />
) : (
<IconUser />
);
<CMDKAction display={{label: t('Assign to'), icon: assigneeIcon}}>
{/* children */}
</CMDKAction>;
```
### 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';
<CMDKAction
display={{label: t('Switch Project')}}
prompt={t('Select a project...')}
limit={5}
resource={(query, context) =>
cmdkQueryOptions({
...apiOptions.as<Project[]>()('/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: <ProjectAvatar project={project} size={16} />,
},
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 <CMDKAction key={index} {...item} />;
if ('onAction' in item) return <CMDKAction key={index} {...item} />;
return null;
}
<CMDKAction
display={{label: t('Assign to')}}
prompt={t('Search teammates...')}
resource={(query, context) =>
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 */}
<CMDKAction display={{label: t('Assign to me')}} onAction={assignToMe} />
{/* Dynamic entries from resource */}
{members.map(renderAsyncResult)}
</>
)}
</CMDKAction>;
```
**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);
<CMDKAction display={{label: t('Assign to'), icon: assigneeIcon}}>
<CMDKAction
display={{label: t('Assign to me')}}
onAction={() => handleAssign(currentUser)}
/>
{assignableUsers.map(member => (
<CMDKAction
key={`member-${member.id}`}
display={{
label: member.name || member.email,
icon: (
<ActorAvatar
actor={{id: member.id, name: member.name, type: 'user'}}
size={16}
hasTooltip={false}
/>
),
}}
onAction={() => handleAssign(member)}
/>
))}
{teams.map(team => (
<CMDKAction
key={`team-${team.id}`}
display={{
label: `#${team.slug}`,
icon: <TeamAvatar team={team} size={16} hasTooltip={false} />,
}}
onAction={() => handleAssign(team)}
/>
))}
</CMDKAction>;
```
**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
在 GitHub 查看