원클릭으로
cmdk-actions
// 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.
// 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.
Reference and active migration guide for Sentry's cell architecture. Explains what cells and localities are and why they're different, how requests reach cells via Synapse API routing, ingestion routing, and the control silo gateway, and how to safely query cross-cell data without silently missing results. The migration section covers how to do migration work: draining the URL_NAME_TO_ACTION registry in test_urls.py to zero (with a recipe for each action type), rolling deploy safety and the two-phase pattern required by independent sentry/getsentry deploys, and the region -> cell rename including what not to rename (DB columns, AWS refs, uptime regions, billing address). Also documents known issues with proposed fixes: org listing and creation without a slug, integration TeamLinkageView routing, Jira cross-cell fan-out, and relocation endpoint routing.
Guide for migrating forms from the legacy JsonForm/FormModel system to the new TanStack-based form system.
Guide for creating forms using Sentry's new form system. Use when implementing forms, form fields, validation, or auto-save functionality.
Generate Django database migrations for Sentry. Use when creating migrations, adding/removing columns or tables, adding indexes, or resolving migration conflicts.
Create a new ESLint rule with tests for eslintPluginScraps. Use when asked to "create a lint rule", "add an eslint rule", "scaffold a rule", "write a new scraps rule", or "new design system lint rule". Covers rule creation, test authoring, registration, and autofix implementation.
Design Django ORM models for Sentry following architectural conventions for silos, replication, relocation, and foreign keys. Use when adding a new Django model, designing a model for a feature, deciding where data should live, picking a foreign key type, or refactoring an existing model's silo placement. Trigger on "add a Django model", "create a model", "design a model for X", "new database table", "store this data in the DB", "I need to track Y", "model for [feature]". Not for Pydantic models, dataclasses, ML models, or Protobuf — this is specifically for Django ORM models in the Sentry codebase.
| 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. |
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.
static/app/components/commandPalette/ui/cmdk.tsx — CMDKAction component (the only primitive you need)static/app/components/commandPalette/types.tsx — public types + cmdkQueryOptions helperstatic/app/components/commandPalette/ui/commandPaletteSlot.tsx — CommandPaletteSlot for scopingstatic/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx — always-on global actionsSlots control sort order and lifetime. Import from commandPaletteSlot.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:
// 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 Propsinterface 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;
}
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import {IconIssues} from 'sentry/icons';
<CMDKAction
display={{
label: t('Go to Issues'),
icon: <IconIssues />,
}}
keywords={['bugs', 'errors', 'problems']}
to={`/organizations/${org.slug}/issues/`}
/>;
<CMDKAction
display={{label: t('Resolve Issue'), details: t('Mark as resolved')}}
onAction={() => handleResolve(group.id)}
/>
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:
// 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>;
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.
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:
cmdkQueryOptions(...) — this injects meta: { cmdk: true } so the palette's loading spinner tracks the request via useIsFetching.enabled: context.state === 'selected' to defer fetching until the user actually drills in.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.staleTime: Infinity for data that rarely changes (project lists, settings nav items). Use staleTime: 30_000 for user/session data.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:
// 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.
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:
// 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}.
trailingItem — marking the active itemUse 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.
import {Tag} from '@sentry/scraps/badge';
// ✅ Entity selection — badge makes sense: user sees which project they're on
<CMDKAction
display={{label: t('Switch Project')}}
prompt={t('Select a project...')}
resource={(query, context) => cmdkQueryOptions({...})}
>
{/* Current project rendered statically so it always appears first */}
<CMDKAction
display={{
label: currentProject.slug,
icon: <ProjectAvatar project={currentProject} size={16} />,
trailingItem: <Tag variant="muted">{t('Current')}</Tag>,
}}
to={`/organizations/${org.slug}/projects/${currentProject.slug}/`}
/>
</CMDKAction>
// ❌ Settings/mode — do not use a badge; the group label already shows the active value
<CMDKAction
display={{
label: t('Sort by: %s', getSortLabel(sort)), // label reflects current state
icon: <IconSort />,
}}
>
{sortKeys.map(key => (
<CMDKAction
key={key}
display={{
label: getSortLabel(key),
// trailingItem: key === sort ? <Tag variant="muted">{t('Current')}</Tag> : undefined
// ❌ Don't do this — the group label already communicates the active sort
}}
onAction={() => onSortChange(key)}
/>
))}
</CMDKAction>
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:
const DSN_PATTERN = /^https?:\/\/.+@.+\/.+/;
<CMDKAction
display={{label: t('DSN Lookup')}}
prompt={t('Paste a DSN...')}
resource={(query, context) =>
cmdkQueryOptions({
...apiOptions.as<DsnLookupResponse>()(/* ... */),
// Only fetch when the query looks like a DSN
enabled: context.state === 'selected' && DSN_PATTERN.test(query),
select: result => result.navTargets.map(/* ... */),
})
}
/>;
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:
<CommandPaletteSlot name="page">
<CMDKAction display={{label: issueTitle}} icon={<ProjectAvatar ... />}>
{!isResolved && !isArchived && (
<CMDKAction display={{label: t('Resolve')}} onAction={handleResolve} />
)}
{!isResolved && !isArchived && (
<CMDKAction display={{label: t('Archive')}} onAction={handleArchive} />
)}
{isResolved && (
<CMDKAction display={{label: t('Unresolve')}} onAction={handleUnresolve} />
)}
{isArchived && (
<CMDKAction display={{label: t('Unarchive')}} onAction={handleUnarchive} />
)}
</CMDKAction>
</CommandPaletteSlot>
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.
<CMDKAction
id="cmdk:supplementary:help"
display={{label: t('Help')}}
resource={helpResource}
/>
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 <CMDKAction> nodes directly:
// views/issueDetails/groupPriorityActions.tsx
// No slot here — registers under whatever parent mounts this
function GroupPriorityActions({group}: {group: Group}) {
return (
<CMDKAction display={{label: t('Set Priority')}}>
<CMDKAction display={{label: t('High')}} onAction={() => setPriority('high')} />
<CMDKAction display={{label: t('Medium')}} onAction={() => setPriority('medium')} />
<CMDKAction display={{label: t('Low')}} onAction={() => setPriority('low')} />
</CMDKAction>
);
}
// 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 (
<Fragment>
<CMDKAction
display={{label: t('Fix with Seer'), icon: <IconSeer />}}
onAction={startAutofix}
/>
</Fragment>
);
}
// views/issueDetails/issueCommandPaletteActions.tsx
// Only this component owns the slot
function IssueCommandPaletteActions({group, issue}: Props) {
return (
<CommandPaletteSlot name="page">
<CMDKAction
display={{
label: issue.title,
icon: <ProjectAvatar project={project} size={16} />,
}}
>
<GroupPriorityActions group={group} />
<SeerActions group={group} />
</CMDKAction>
</CommandPaletteSlot>
);
}
Use <Fragment> (not a wrapping <CMDKAction>) when a child component contributes flat siblings into an existing parent group.
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:
// Inside GlobalCommandPaletteActions render:
<CMDKAction display={{label: t('Go to')}}>
{/* existing actions... */}
<CMDKAction
display={{label: t('Monitors'), icon: <IconTimer />}}
to={`/organizations/${organization.slug}/crons/`}
/>
</CMDKAction>
Create a component that wraps actions in <CommandPaletteSlot name="page"> and mount it inside the relevant page component. The actions register and deregister automatically with the page's mount/unmount lifecycle.
// views/myFeature/myFeatureCommandPaletteActions.tsx
function MyFeatureCommandPaletteActions({item}: {item: MyItem}) {
return (
<CommandPaletteSlot name="page">
<CMDKAction
display={{label: t('Archive'), details: item.name}}
onAction={() => archiveItem(item.id)}
/>
</CommandPaletteSlot>
);
}
// views/myFeature/myFeaturePage.tsx
function MyFeaturePage() {
return (
<div>
<MyFeatureCommandPaletteActions item={item} />
{/* rest of page */}
</div>
);
}
Gate on additional flags or permissions inline:
{
organization.features.includes('my-feature') && (
<CMDKAction display={{label: t('My New Action')}} onAction={doThing} />
);
}
{
user.isStaff && <CMDKAction display={{label: t('Admin Panel')}} to="/admin/" />;
}
Gate the entire slot when a page is disabled — don't render individual disabled actions; don't render the slot at all:
// ✅ Gate at the slot level
{
!disabled && (
<CommandPaletteSlot name="page">
<CMDKAction display={{label: entity.title}}>{/* all actions */}</CMDKAction>
</CommandPaletteSlot>
);
}
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:
const config = useMemo(() => getConfigForIssueType(group, project), [group, project]);
const {
actions: {resolve: resolveCap, delete: deleteCap},
} = config;
// Only render actions the issue type supports
{
resolveCap.enabled && (
<CMDKAction display={{label: t('Resolve')}} onAction={handleResolve} />
);
}
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.
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:
// 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 (
<Fragment>
{(!autofix.runState || autofix.runState.status === 'error') && (
<CMDKAction display={{label: t('Fix with Seer')}} onAction={startFix} />
)}
{canContinue && completedRootCause && !completedSolution && (
<CMDKAction
display={{label: t('Generate solution')}}
onAction={() => nextStep('solution', runId)}
/>
)}
{canContinue && completedSolution && !completedCodeChanges && (
<CMDKAction
display={{label: t('Generate code changes')}}
onAction={() => nextStep('code_changes', runId)}
/>
)}
{canContinue && completedCodeChanges && !hasPR && (
<CMDKAction
display={{label: t('Open pull request')}}
onAction={() => createPR(runId)}
/>
)}
</Fragment>
);
}
Key points:
use*State hook within the action component file — keeps the JSX clean.canContinue guard to prevent showing progress actions while an async operation is in flight.null early at the top of the component when the feature isn't applicable:// Guard clause — return null before any hooks if possible, else after
if (!aiConfig.areAiFeaturesAllowed || !isExplorer || !issueTypeSupportsSeer || !event) {
return null;
}
Embed the current value in an action label to give context without requiring the user to drill in first:
// Shows who is currently assigned
<CMDKAction
display={{label: t('Unassign from %s', currentAssigneeName)}}
onAction={() => handleAssigneeChange(null)}
/>
// Shows the current value being changed
<CMDKAction
display={{label: t('Change theme: %s', currentTheme)}}
onAction={openThemePicker}
/>
Use t('... %s', value) (printf-style) rather than template literals so strings remain translatable.
<CommandPaletteSlot name="page">; add global actions to GlobalCommandPaletteActionsresource 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 placeholderlimit 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 dataid="cmdk:supplementary:..." on any section that should always sort lastkeywords added for non-obvious actions to improve search recallProjectAvatar, ActorAvatar, TeamAvatar) use size={16}disabled state gates the entire <CommandPaletteSlot>, not individual actionst('... %s', value) not template literals, so strings stay translatableuse*State hook and use a canContinue guardnull early via a guard clause before rendering any JSXgetConfigForIssueType) drives action availability rather than scattered type checkstype-id format (member-${id}, team-${id}) to prevent cross-type collisions