From 7b1717b1721d0d785ee16dfa1890db76d04ab233 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Sun, 5 Apr 2026 19:35:13 -0700 Subject: [PATCH 01/43] ref(cmdk): implement typed collection factory for JSX-based action registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a makeCollection() factory in ui/collection.tsx that creates isolated, fully-typed collection instances. Each instance tracks a flat node map and a parent→children index, exposing a tree(rootKey?) API for structured traversal. A node becomes a group by wrapping its children in GroupContext.Provider — there is no separate group/item type distinction. Data fields are spread directly onto tree nodes so consumers can access them without any cast. ui/cmdk.tsx builds the CMDK-specific layer on top: CMDKCollection, CMDKGroup, and CMDKAction. Groups and actions share a single CMDKActionData union type. CMDK_PLAN.md tracks the remaining migration steps from the old reducer-based registration system. Co-Authored-By: Claude Sonnet 4.6 --- .../components/commandPalette/CMDK_PLAN.md | 194 +++++++++ .../app/components/commandPalette/ui/cmdk.tsx | 81 ++++ .../commandPalette/ui/collection.tsx | 202 +++++++++ .../commandPalette/ui/commandPalette.tsx | 26 ++ .../components/commandPalette/ui/poc.spec.tsx | 408 ++++++++++++++++++ 5 files changed, 911 insertions(+) create mode 100644 static/app/components/commandPalette/CMDK_PLAN.md create mode 100644 static/app/components/commandPalette/ui/cmdk.tsx create mode 100644 static/app/components/commandPalette/ui/collection.tsx create mode 100644 static/app/components/commandPalette/ui/poc.spec.tsx diff --git a/static/app/components/commandPalette/CMDK_PLAN.md b/static/app/components/commandPalette/CMDK_PLAN.md new file mode 100644 index 00000000000000..88354b39b7c2c6 --- /dev/null +++ b/static/app/components/commandPalette/CMDK_PLAN.md @@ -0,0 +1,194 @@ +# CMDK Migration Plan + +Migrate the command palette action registration system from the reducer-based +`useCommandPaletteActionsRegister` model to a JSX/React-context collection model. + +## Background + +`ui/collection.tsx` exports a `makeCollection()` factory. Each call creates an +isolated, fully-typed collection instance with its own React contexts. The factory +takes a single type parameter `T` — the data shape shared by all nodes. There is no +separate group/item type: a node becomes a group by wrapping its children in +`GroupContext.Provider`, not by carrying a `type` field. + +The factory returns: + +```ts +{ + Provider, // root store provider + GroupContext, // string context — propagates nearest parent group key + useStore(), // returns CollectionStore with register/unregister/tree() + useRegisterNode(data: T): string, // registers a node, returns its stable key +} +``` + +`tree()` returns `CollectionTreeNode[]` where each node is: + +```ts +{ key: string; parent: string | null; children: CollectionTreeNode[] } & T +``` + +Data fields are spread directly onto the node — no `.data` wrapper. A node is a +group if `node.children.length > 0`. + +On top of this, `ui/cmdk.tsx` defines: + +```ts +// Single data shape — groups are nodes that happen to have children +export type CMDKActionData = + | { display: DisplayProps; to: string; keywords?: string[] } + | { display: DisplayProps; onAction: () => void; keywords?: string[] } + | { display: DisplayProps; resource?: (...) => CMDKQueryOptions; keywords?: string[] }; + +export const CMDKCollection = makeCollection(); +``` + +`CMDKGroup` calls `CMDKCollection.useRegisterNode(data)` and wraps children in +`CMDKCollection.GroupContext.Provider`. `CMDKAction` calls `CMDKCollection.useRegisterNode(data)` +and renders `null`. + +The palette consumes `CMDKCollection.useStore().tree()` instead of `useCommandPaletteActions()`. + +## Todo + +### Step 1 — Extend the data model ✅ Done + +The collection factory, typed data shapes, `CMDKGroup`, and `CMDKAction` are all +implemented in `ui/collection.tsx` and `ui/cmdk.tsx`. `poc.spec.tsx` covers +registration, unregistration, and data freshness. + +### Step 2 — Add CMDKQueryContext + +Async groups (`CMDKGroup` with `resource`) need the current search query to call +`resource(query)`. The query lives in `CommandPaletteStateContext` (`state.query`). + +- [ ] Add `CMDKQueryContext = createContext('')` to `ui/cmdk.tsx` +- [ ] Update `CMDKCollection.Provider` to also provide `CMDKQueryContext`: + ```tsx + function CMDKCollectionProvider({children}) { + const {query} = useCommandPaletteState(); + return ( + + {children} + + ); + } + ``` + Export this as `CMDKProvider` — callers use this instead of `CMDKCollection.Provider` directly. +- [ ] In `CMDKGroup`, read `const query = useContext(CMDKQueryContext)` +- [ ] When `resource` prop is present, call `useQuery({ ...resource(query), enabled: !!resource })` + inside `CMDKGroup` +- [ ] Resolve children based on whether `children` is a render prop: + ```ts + const resolvedChildren = + typeof children === 'function' ? (data ? children(data) : null) : children; + ``` +- [ ] Wrap resolved children in `` as before + +### Step 3 — Wire CMDKProvider into the provider tree + +`CMDKProvider` (from Step 2) must sit inside `CommandPaletteStateProvider` because it +reads from `useCommandPaletteState()`. + +- [ ] Find where `CommandPaletteProvider` and `CommandPaletteStateProvider` are mounted — + search for `CommandPaletteProvider` in the codebase to locate the mount point +- [ ] Place `` as a child of `CommandPaletteStateProvider`, wrapping + whatever subtree currently lives inside it +- [ ] Verify no runtime errors — the collection store is live but empty + +### Step 4 — Convert global actions to a JSX component + +`useGlobalCommandPaletteActions` calls `useCommandPaletteActionsRegister([...actions])` +with a large static action tree. Replace it with a component that renders the equivalent +JSX tree. The old hook stays alive during this step so both systems run in parallel. + +- [ ] Create `GlobalCommandPaletteActions` component (can live in `useGlobalCommandPaletteActions.tsx` + or a new file `globalActions.tsx`) +- [ ] Translate each section — read `useGlobalCommandPaletteActions.tsx` carefully before translating: + - [ ] **Navigation** — one `` containing a + `` per destination (Issues, Explore, Dashboards, Insights, Settings) + - [ ] **Create** — one `` with `` for Dashboard, + Alert, Project, Invite Members + - [ ] **DSN Lookup** — `` with render-prop children: + `{data => data.map(item => )}` + - [ ] **Help** — static `` nodes for Docs/Discord/GitHub/Changelog plus a + `` with render-prop children for search results + - [ ] **Interface** — `` for navigation toggle and theme switching +- [ ] Mount `` inside `` in the provider tree +- [ ] Verify `CMDKCollection.useStore().tree()` returns the expected structure by adding a + temporary log or test — do not remove old system yet + +### Step 5 — Update the palette UI to read from the collection store + +`commandPalette.tsx` currently drives all data through `useCommandPaletteActions()` → +`scoreTree()` → `flattenActions()` → `collectResourceActions()`. Replace this pipeline +with the collection store. + +- [ ] Replace `const actions = useCommandPaletteActions()` with + `const store = CMDKCollection.useStore()` in `commandPalette.tsx` +- [ ] Rewrite `scoreTree()` to accept `CollectionTreeNode[]`. Data fields + are spread directly onto nodes — access `node.display.label`, `node.display.details`, + and `node.keywords` directly (no `node.data.*` indirection) +- [ ] Rewrite `flattenActions()` to accept `CollectionTreeNode[]` with the + same direct field access +- [ ] A node is a group if `node.children.length > 0` — replace any `node.type === 'group'` + checks with this +- [ ] Remove `collectResourceActions()` entirely — async fetching is now handled inside + `CMDKGroup` before nodes appear in the tree. The `resource` field never reaches the consumer. +- [ ] Replace the linked-list action stack navigation with the collection's `tree(rootKey)` API: + - When the user navigates into a group, store that group's `key` as the current root + - Call `store.tree(currentRootKey)` to get the subtree to display + - Going back means popping the key stack + - Update `commandPaletteStateContext.tsx` `push action` / `pop action` to store node + keys instead of full action objects +- [ ] Update `modal.tsx` `handleSelect`: `to` and `onAction` are now direct fields on the + node (e.g. `node.to`, `node.onAction`) — no `.data` wrapper, no cast needed +- [ ] Run `CI=true pnpm test static/app/components/commandPalette` and fix failures + +### Step 6 — Remove the old registration infrastructure + +Only do this after Step 5 passes all tests. + +- [ ] Search for all callers of `useCommandPaletteActionsRegister` outside of + `useGlobalCommandPaletteActions.tsx` — these are page-scoped action registrations. + For each one, create a component that renders `` / `` and mount + it in the relevant page's component tree instead. +- [ ] Delete the reducer (`actionsReducer`), `addKeysToActions`, `addKeysToChildActions` + from `context.tsx` +- [ ] Remove `CommandPaletteActionsContext` and `useCommandPaletteActions` from `context.tsx` +- [ ] Remove `CommandPaletteRegistrationContext` and `useCommandPaletteActionsRegister` from `context.tsx` +- [ ] Remove or simplify `CommandPaletteProvider` — if it only wrapped the two contexts above + it can be deleted; if it serves other purposes keep a slimmed version +- [ ] Remove the old `useGlobalCommandPaletteActions` hook (replaced by `GlobalCommandPaletteActions`) +- [ ] Clean up `types.tsx`: remove `CommandPaletteActionWithKey` and its variants + (`CommandPaletteActionLinkWithKey`, `CommandPaletteActionCallbackWithKey`, etc.) — these + were only needed because the old system added keys at registration time. The new system + uses `useId()` inside each component. +- [ ] Run the full test suite and typecheck: `CI=true pnpm test` and `pnpm run typecheck` + +## Key files + +| File | Role | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `ui/collection.tsx` | Generic factory — `makeCollection()` returns `{Provider, GroupContext, useStore, useRegisterNode}` | +| `ui/cmdk.tsx` | CMDK layer — `CMDKActionData`, `CMDKCollection`, `CMDKGroup`, `CMDKAction`, `CMDKProvider` (added in Step 2) | +| `ui/commandPalette.tsx` | Palette UI — reads from collection store, scoring, flattening, keyboard nav | +| `ui/commandPaletteStateContext.tsx` | UI state — query string, open/close, navigation stack | +| `ui/modal.tsx` | Modal wrapper — executes selected actions via `to` or `onAction` | +| `context.tsx` | Old registration system — deleted in Step 6 | +| `useGlobalCommandPaletteActions.tsx` | Global actions — replaced by JSX component in Step 4 | +| `types.tsx` | Shared types — `WithKey` variants removed in Step 6 | + +## Notes + +- Steps 2 and 3 are independent and can be done in parallel. +- Step 6 is only safe once Step 5 is complete and all tests pass. +- Item reordering (items that change position without unmounting) is a known limitation of + the collection model — it is documented and intentionally deferred. Do not block the + migration on it. +- `useId()` inside `CMDKGroup`/`CMDKAction` replaces the old `uuid4()` + slug key generation + in `addKeysToActions`. Keys are now stable across renders but reset on remount. +- There is no `type: 'group' | 'item'` on tree nodes. A node is a group if + `node.children.length > 0`. An empty async group (loading state) will appear as a leaf + until its children mount — handle this at the rendering layer if needed (e.g. check + for `resource` on the node data to show a loading indicator). diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx new file mode 100644 index 00000000000000..9fd7ba72282783 --- /dev/null +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -0,0 +1,81 @@ +import type {ReactNode} from 'react'; + +import type { + CMDKQueryOptions, + CommandPaletteAsyncResult, +} from 'sentry/components/commandPalette/types'; + +import {makeCollection} from './collection'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DisplayProps { + label: string; + details?: string; + icon?: ReactNode; +} + +/** + * Single data shape for all CMDK nodes. A node becomes a group by virtue of + * having children registered under it — there is no separate group type. + */ +export type CMDKActionData = + | {display: DisplayProps; to: string; keywords?: string[]} + | {display: DisplayProps; onAction: () => void; keywords?: string[]} + | { + display: DisplayProps; + keywords?: string[]; + resource?: (query: string) => CMDKQueryOptions; + }; + +// --------------------------------------------------------------------------- +// Typed collection instance for CMDK +// --------------------------------------------------------------------------- + +export const CMDKCollection = makeCollection(); + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +interface CMDKGroupProps { + display: DisplayProps; + children?: ReactNode | ((data: CommandPaletteAsyncResult[]) => ReactNode); + keywords?: string[]; + resource?: (query: string) => CMDKQueryOptions; +} + +type CMDKActionProps = + | {display: DisplayProps; to: string; keywords?: string[]} + | {display: DisplayProps; onAction: () => void; keywords?: string[]}; + +/** + * Registers a node in the collection and propagates its key to children via + * GroupContext so they register as its children. + * + * Does not render any UI — rendering is handled by a separate consumer of the + * collection store. + */ +export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { + const key = CMDKCollection.useRegisterNode({display, keywords, resource}); + const resolvedChildren = typeof children === 'function' ? null : children; + + return ( + + {resolvedChildren} + + ); +} + +/** + * Registers a leaf action node in the collection. + * + * Does not render any UI — rendering is handled by a separate consumer of the + * collection store. + */ +export function CMDKAction(props: CMDKActionProps) { + CMDKCollection.useRegisterNode(props); + return null; +} diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx new file mode 100644 index 00000000000000..e13eebd928a7c5 --- /dev/null +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -0,0 +1,202 @@ +import { + createContext, + useContext, + useId, + useLayoutEffect, + useMemo, + useReducer, + useRef, +} from 'react'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type StoredNode = { + dataRef: React.MutableRefObject; + key: string; + parent: string | null; +}; + +/** + * A node as returned by tree(). Plain data fields are spread alongside the + * structural fields (key, parent, children). + * + * A node is a "group" if it has children — there is no separate type + * discriminant. The collection is purely a structural container. + */ +export type CollectionTreeNode = { + children: Array>; + key: string; + parent: string | null; +} & T; + +export interface CollectionStore { + register: (node: StoredNode) => void; + /** + * Reconstruct the subtree rooted at rootKey. + * Pass null (default) to get the full tree from the top. + */ + tree: (rootKey?: string | null) => Array>; + unregister: (key: string) => void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export interface CollectionInstance { + /** + * Propagates the nearest parent group's key to children. + * Use it in Group components to wrap children so they register under this node: + * + */ + GroupContext: React.Context; + + /** + * Root provider. Wrap your node tree in this component. + */ + Provider: (props: {children: React.ReactNode}) => React.ReactElement; + + /** + * Registers a node on mount, unregisters on unmount. + * Returns the stable key assigned to this node. + * + * To make this node a group (i.e. allow it to have children), wrap its + * children in . + */ + useRegisterNode: (data: T) => string; + + /** + * Returns the typed collection store. Call tree() to reconstruct the node + * tree at any time. + */ + useStore: () => CollectionStore; +} + +/** + * Creates a typed collection instance. Call once at module level. + * + * There is a single type parameter T — the data shape shared by all nodes. + * A node becomes a "group" by virtue of having children registered under it + * (via GroupContext), not by having a separate type. + * + * @example + * const CMDKCollection = makeCollection(); + * + * function CMDKGroup({ data, children }) { + * const key = CMDKCollection.useRegisterNode(data); + * return {children}; + * } + * + * function CMDKAction({ data }) { + * CMDKCollection.useRegisterNode(data); + * return null; + * } + */ +export function makeCollection(): CollectionInstance { + const StoreContext = createContext | null>(null); + const GroupContext = createContext(null); + + // ------------------------------------------------------------------------- + // Provider + // ------------------------------------------------------------------------- + + function Provider({children}: {children: React.ReactNode}) { + const nodes = useRef(new Map>()); + + // Secondary index: parent key → ordered Set of child keys. + // Insertion order = JSX order (guaranteed by React's depth-first left-to-right + // effect ordering: siblings register before their next sibling's subtree fires). + const childIndex = useRef(new Map>()); + + // Tracks whether any registrations happened since the last flush. + // register/unregister mutate refs and increment this counter. They do NOT call + // bump() directly — that would cause a synchronous re-render mid-registration + // and leave consumers seeing a partial tree. + const pendingVersion = useRef(0); + const flushedVersion = useRef(0); + + const [, bump] = useReducer(x => x + 1, 0); + + const store = useMemo>( + () => ({ + register(node) { + nodes.current.set(node.key, node); + const siblings = childIndex.current.get(node.parent) ?? new Set(); + siblings.add(node.key); + childIndex.current.set(node.parent, siblings); + pendingVersion.current++; + }, + + unregister(key) { + const node = nodes.current.get(key); + if (!node) return; + nodes.current.delete(key); + childIndex.current.get(node.parent)?.delete(key); + childIndex.current.delete(key); + pendingVersion.current++; + }, + + tree(rootKey = null): Array> { + const childKeys = childIndex.current.get(rootKey) ?? new Set(); + return [...childKeys].map(key => { + const node = nodes.current.get(key)!; + return { + key: node.key, + parent: node.parent, + children: this.tree(key), + ...node.dataRef.current, + } as CollectionTreeNode; + }); + }, + }), + [] + ); + + // This effect runs AFTER all descendants' useLayoutEffects (parent fires last). + // If registrations changed since the last flush, trigger one re-render so + // consumers see the complete, stable tree. + useLayoutEffect(() => { + if (pendingVersion.current !== flushedVersion.current) { + flushedVersion.current = pendingVersion.current; + bump(); + } + }); + + return {children}; + } + + // ------------------------------------------------------------------------- + // Hooks + // ------------------------------------------------------------------------- + + function useStore(): CollectionStore { + const store = useContext(StoreContext); + if (!store) { + throw new Error('useStore must be called inside the matching Collection Provider'); + } + return store; + } + + function useRegisterNode(data: T): string { + const store = useStore(); + const parentKey = useContext(GroupContext); + const key = useId(); + + // Store data in a ref so tree() always reflects the latest value without + // needing to re-register when data changes. Structural changes (parentKey) + // still cause a full re-registration via the effect deps. + const dataRef = useRef(data); + dataRef.current = data; + + useLayoutEffect(() => { + store.register({key, parent: parentKey, dataRef}); + return () => store.unregister(key); + }, [key, parentKey, store]); + + return key; + } + + return {Provider, GroupContext, useStore, useRegisterNode}; +} diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 5b28ce086c664b..e475ff60531bc5 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -410,10 +410,36 @@ export function CommandPalette(props: CommandPaletteProps) { /> )} + + + + + + ); } +interface ItemProps { + children?: React.ReactNode; + name?: string; +} + +function Group(props: ItemProps) { + return props.children; +} + +function Item(props: ItemProps) { + return props.children; +} + +// JSX traversal + +function Collection({children}: {children: React.ReactNode}) { + useLayoutEffect(() => {}, [children]); + return children; +} + function collectResourceActions( root: CommandPaletteActionWithKey, isSearching: boolean diff --git a/static/app/components/commandPalette/ui/poc.spec.tsx b/static/app/components/commandPalette/ui/poc.spec.tsx new file mode 100644 index 00000000000000..46911be860b65b --- /dev/null +++ b/static/app/components/commandPalette/ui/poc.spec.tsx @@ -0,0 +1,408 @@ +import React from 'react'; + +import {render} from 'sentry-test/reactTestingLibrary'; + +import {slot} from '@sentry/scraps/slot'; + +import {makeCollection} from './collection'; + +// --------------------------------------------------------------------------- +// Shared test collection + components +// --------------------------------------------------------------------------- + +interface NodeData { + name: string; +} + +const TestCollection = makeCollection(); + +function Group({children, name}: {name: string; children?: React.ReactNode}) { + const key = TestCollection.useRegisterNode({name}); + return ( + + {children} + + ); +} + +function Item({name}: {name: string}) { + TestCollection.useRegisterNode({name}); + return null; +} + +function StoreCapture({ + storeRef, +}: { + storeRef: React.MutableRefObject | null>; +}) { + storeRef.current = TestCollection.useStore(); + return null; +} + +function makeStoreRef() { + return React.createRef() as React.MutableRefObject | null>; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Collection', () => { + it('builds the tree from JSX structure', () => { + const storeRef = makeStoreRef(); + + render( + + + + + + + + + + + ); + + const tree = storeRef.current!.tree(); + expect(tree).toHaveLength(1); + + const [titleGroup] = tree; + expect(titleGroup.name).toBe('Title'); + expect(titleGroup.parent).toBeNull(); + expect(titleGroup.children).toHaveLength(2); + + const [item1, nestedGroup] = titleGroup.children; + expect(item1.name).toBe('Item 1'); + expect(item1.children).toHaveLength(0); + expect(nestedGroup.name).toBe('Item 2'); + expect(nestedGroup.children).toHaveLength(2); + }); + + it('preserves JSX sibling order in the tree', () => { + const storeRef = makeStoreRef(); + + render( + + + + + + + ); + + const names = storeRef.current!.tree().map(n => n.name); + expect(names).toEqual(['first', 'second', 'third']); + }); + + it('preserves JSX order when siblings include groups with deep children', () => { + const storeRef = makeStoreRef(); + + render( + + + + + + + + + + ); + + const names = storeRef.current!.tree().map(n => n.name); + expect(names).toEqual(['before', 'middle', 'after']); + }); + + it('returns a subtree rooted at a given key via tree(rootKey)', () => { + const storeRef = makeStoreRef(); + + render( + + + + + + + + + + + ); + + const [groupA] = storeRef.current!.tree(); + const subtree = storeRef.current!.tree(groupA.key); + expect(subtree.map(n => n.name)).toEqual(['A1', 'A2']); + }); + + it('unregisters nodes when they unmount', () => { + const storeRef = makeStoreRef(); + + const {rerender} = render( + + + + + + + ); + + expect(storeRef.current!.tree()).toHaveLength(1); + + rerender( + + + + ); + + expect(storeRef.current!.tree()).toHaveLength(0); + }); + + it('unregisters a removed node while leaving siblings intact', () => { + const storeRef = makeStoreRef(); + + // Explicit React keys so reconciliation preserves Group B's component instance + // (and its registered collection key) when Group A is removed. + const {rerender} = render( + + + + + + + + + + ); + + const groupBKey = storeRef.current!.tree()[1].key; + + rerender( + + + + + + + ); + + expect(storeRef.current!.tree()).toHaveLength(1); + expect(storeRef.current!.tree(groupBKey).map(n => n.name)).toEqual(['B1']); + }); + + it('updates the tree when a conditional node mounts and unmounts', () => { + const storeRef = makeStoreRef(); + + const {rerender} = render( + + + + + ); + + rerender( + + + + + + ); + + expect(storeRef.current!.tree().map(n => n.name)).toEqual(['always', 'conditional']); + + rerender( + + + + + ); + + expect(storeRef.current!.tree().map(n => n.name)).toEqual(['always']); + }); + + it('reflects updated data without re-registering', () => { + const storeRef = makeStoreRef(); + + const {rerender} = render( + + + + + ); + + rerender( + + + + + ); + + expect(storeRef.current!.tree()[0].name).toBe('updated'); + }); + + it('propagates parent keys correctly at 3+ levels of nesting', () => { + const storeRef = makeStoreRef(); + + render( + + + + + + + + + + + ); + + const [l1] = storeRef.current!.tree(); + expect(l1.parent).toBeNull(); + + const [l2] = l1.children; + expect(l2.parent).toBe(l1.key); + + const [l3] = l2.children; + expect(l3.parent).toBe(l2.key); + + const [deep] = l3.children; + expect(deep.parent).toBe(l3.key); + expect(deep.name).toBe('deep'); + }); + + it('isolates two independent collection instances', () => { + const A = makeCollection(); + const B = makeCollection(); + + const storeRefA = {current: null} as React.MutableRefObject | null>; + const storeRefB = {current: null} as React.MutableRefObject | null>; + + function ItemA({name}: {name: string}) { + A.useRegisterNode({name}); + return null; + } + function ItemB({name}: {name: string}) { + B.useRegisterNode({name}); + return null; + } + function CaptureA() { + storeRefA.current = A.useStore(); + return null; + } + function CaptureB() { + storeRefB.current = B.useStore(); + return null; + } + + render( + + + + + + + + + + + ); + + expect(storeRefA.current!.tree().map(n => n.name)).toEqual(['a-item']); + expect(storeRefB.current!.tree().map(n => n.name)).toEqual(['b-item']); + }); + + it('items portaled via Slot register at their declaration site, not the outlet location', () => { + // This test verifies that React portals preserve component tree context. + // An Item declared inside a Slot.Consumer should register in the collection + // based on the GroupContext at the Consumer's position — not the Outlet's position. + const ActionSlot = slot(['actions'] as const); + const storeRef = makeStoreRef(); + + render( + + + {/* Outlet is nested inside a Group — this is where items appear in the DOM */} + + + {({ref}) =>
} + + + + {/* Consumer is at root level (outside any Group) — this is where context is read */} + + + + + + + + ); + + const tree = storeRef.current!.tree(); + const outletGroup = tree.find(n => n.name === 'outlet-group')!; + const slottedItem = tree.find(n => n.name === 'slotted-item')!; + + // Item appears in the DOM inside outlet-group, but registers at root level + // because the Slot.Consumer was mounted outside any Group. + expect(slottedItem).toBeDefined(); + expect(slottedItem.parent).toBeNull(); + expect(outletGroup.children.map(n => n.name)).not.toContain('slotted-item'); + }); + + it('items portaled via Slot register under the Group wrapping the Consumer', () => { + const ActionSlot = slot(['actions'] as const); + const storeRef = makeStoreRef(); + + render( + + + {/* Consumer is inside source-group — items pick up that GroupContext */} + + + + + + + {/* Outlet is in a different Group — items portal here in the DOM */} + + + {({ref}) =>
} + + + + + + + ); + + const tree = storeRef.current!.tree(); + const sourceGroup = tree.find(n => n.name === 'source-group')!; + const targetGroup = tree.find(n => n.name === 'target-group')!; + + // Item registers under source-group (where Consumer was declared), + // not target-group (where it portals in the DOM). + expect(sourceGroup.children.map(n => n.name)).toContain('slotted-item'); + expect(targetGroup.children.map(n => n.name)).not.toContain('slotted-item'); + }); + + it('throws when useStore is called outside the Provider', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + function BadComponent() { + TestCollection.useStore(); + return null; + } + + expect(() => render()).toThrow( + 'useStore must be called inside the matching Collection Provider' + ); + + consoleSpy.mockRestore(); + }); +}); From 39a8156f6ca2cd623137f756cd5a8e0fba2da2c7 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Sun, 5 Apr 2026 20:24:13 -0700 Subject: [PATCH 02/43] =?UTF-8?q?ref(cmdk):=20migrate=20command=20palette?= =?UTF-8?q?=20to=20JSX=20collection=20model=20(steps=202=E2=80=935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire CMDKQueryContext and CMDKProvider so async CMDKGroup nodes can read the current search query. Mount CMDKProvider inside CommandPaletteStateProvider in the existing CommandPaletteProvider so the collection store is live for the full app lifetime. Add GlobalCommandPaletteActions component that registers all global actions into the CMDK collection via JSX (CMDKGroup / CMDKAction) rather than the old useCommandPaletteActionsRegister reducer hook. Both systems run in parallel during this transition step. Swap the CommandPalette UI to read from CMDKCollection.useStore() instead of the old useCommandPaletteActions() reducer pipeline. Remove collectResourceActions, asyncQueries, and asyncChildrenMap — async fetching is now handled inside CMDKGroup before nodes appear in the tree. Rewrite scoreTree and flattenActions to work with CollectionTreeNode using direct field access. Replace the linked-list navigation stack (which stored full action objects) with CMDKNavStack which stores only the group key and label. Push action now dispatches {key, label} instead of a full CommandPaletteActionWithKey. Update modal.tsx, stories, analytics hook, and tests to use the new types. Fix pre-existing duplicate Item declaration in commandPalette.tsx that caused a Babel parse error in the test suite. Co-Authored-By: Claude Sonnet 4.6 --- .../components/commandPalette/CMDK_PLAN.md | 36 +- .../commandPalette/__stories__/components.tsx | 13 +- .../app/components/commandPalette/context.tsx | 5 +- .../commandPalette/globalActions.tsx | 416 ++++++++++++++++++ .../app/components/commandPalette/ui/cmdk.tsx | 50 ++- .../commandPalette/ui/commandPalette.spec.tsx | 61 ++- .../commandPalette/ui/commandPalette.tsx | 358 ++++----------- .../ui/commandPaletteStateContext.tsx | 24 +- .../components/commandPalette/ui/modal.tsx | 9 +- .../components/commandPalette/ui/poc.spec.tsx | 19 +- .../useCommandPaletteAnalytics.tsx | 33 +- static/app/views/navigation/index.tsx | 2 + 12 files changed, 669 insertions(+), 357 deletions(-) create mode 100644 static/app/components/commandPalette/globalActions.tsx diff --git a/static/app/components/commandPalette/CMDK_PLAN.md b/static/app/components/commandPalette/CMDK_PLAN.md index 88354b39b7c2c6..01dc617898c148 100644 --- a/static/app/components/commandPalette/CMDK_PLAN.md +++ b/static/app/components/commandPalette/CMDK_PLAN.md @@ -62,8 +62,8 @@ registration, unregistration, and data freshness. Async groups (`CMDKGroup` with `resource`) need the current search query to call `resource(query)`. The query lives in `CommandPaletteStateContext` (`state.query`). -- [ ] Add `CMDKQueryContext = createContext('')` to `ui/cmdk.tsx` -- [ ] Update `CMDKCollection.Provider` to also provide `CMDKQueryContext`: +- [x] Add `CMDKQueryContext = createContext('')` to `ui/cmdk.tsx` +- [x] Update `CMDKCollection.Provider` to also provide `CMDKQueryContext`: ```tsx function CMDKCollectionProvider({children}) { const {query} = useCommandPaletteState(); @@ -75,26 +75,26 @@ Async groups (`CMDKGroup` with `resource`) need the current search query to call } ``` Export this as `CMDKProvider` — callers use this instead of `CMDKCollection.Provider` directly. -- [ ] In `CMDKGroup`, read `const query = useContext(CMDKQueryContext)` -- [ ] When `resource` prop is present, call `useQuery({ ...resource(query), enabled: !!resource })` +- [x] In `CMDKGroup`, read `const query = useContext(CMDKQueryContext)` +- [x] When `resource` prop is present, call `useQuery({ ...resource(query), enabled: !!resource })` inside `CMDKGroup` -- [ ] Resolve children based on whether `children` is a render prop: +- [x] Resolve children based on whether `children` is a render prop: ```ts const resolvedChildren = typeof children === 'function' ? (data ? children(data) : null) : children; ``` -- [ ] Wrap resolved children in `` as before +- [x] Wrap resolved children in `` as before ### Step 3 — Wire CMDKProvider into the provider tree `CMDKProvider` (from Step 2) must sit inside `CommandPaletteStateProvider` because it reads from `useCommandPaletteState()`. -- [ ] Find where `CommandPaletteProvider` and `CommandPaletteStateProvider` are mounted — +- [x] Find where `CommandPaletteProvider` and `CommandPaletteStateProvider` are mounted — search for `CommandPaletteProvider` in the codebase to locate the mount point -- [ ] Place `` as a child of `CommandPaletteStateProvider`, wrapping +- [x] Place `` as a child of `CommandPaletteStateProvider`, wrapping whatever subtree currently lives inside it -- [ ] Verify no runtime errors — the collection store is live but empty +- [x] Verify no runtime errors — the collection store is live but empty ### Step 4 — Convert global actions to a JSX component @@ -102,20 +102,20 @@ reads from `useCommandPaletteState()`. with a large static action tree. Replace it with a component that renders the equivalent JSX tree. The old hook stays alive during this step so both systems run in parallel. -- [ ] Create `GlobalCommandPaletteActions` component (can live in `useGlobalCommandPaletteActions.tsx` +- [x] Create `GlobalCommandPaletteActions` component (can live in `useGlobalCommandPaletteActions.tsx` or a new file `globalActions.tsx`) -- [ ] Translate each section — read `useGlobalCommandPaletteActions.tsx` carefully before translating: - - [ ] **Navigation** — one `` containing a +- [x] Translate each section — read `useGlobalCommandPaletteActions.tsx` carefully before translating: + - [x] **Navigation** — one `` containing a `` per destination (Issues, Explore, Dashboards, Insights, Settings) - - [ ] **Create** — one `` with `` for Dashboard, + - [x] **Create** — one `` with `` for Dashboard, Alert, Project, Invite Members - - [ ] **DSN Lookup** — `` with render-prop children: + - [x] **DSN Lookup** — `` with render-prop children: `{data => data.map(item => )}` - - [ ] **Help** — static `` nodes for Docs/Discord/GitHub/Changelog plus a + - [x] **Help** — static `` nodes for Docs/Discord/GitHub/Changelog plus a `` with render-prop children for search results - - [ ] **Interface** — `` for navigation toggle and theme switching -- [ ] Mount `` inside `` in the provider tree -- [ ] Verify `CMDKCollection.useStore().tree()` returns the expected structure by adding a + - [x] **Interface** — `` for navigation toggle and theme switching +- [x] Mount `` inside `` in the provider tree +- [x] Verify `CMDKCollection.useStore().tree()` returns the expected structure by adding a temporary log or test — do not remove old system yet ### Step 5 — Update the palette UI to read from the collection store diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 217c74917e0d7d..36b15d1af6df66 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -3,10 +3,9 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context'; -import type { - CommandPaletteAction, - CommandPaletteActionWithKey, -} from 'sentry/components/commandPalette/types'; +import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -20,13 +19,11 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CommandPaletteActionWithKey) => { + (action: CollectionTreeNode) => { if ('to' in action) { - navigate(normalizeUrl(action.to)); + navigate(normalizeUrl(String(action.to))); } else if ('onAction' in action) { action.onAction(); - } else { - // @TODO: implement async actions } }, [navigate] diff --git a/static/app/components/commandPalette/context.tsx b/static/app/components/commandPalette/context.tsx index 92c63ad162e008..65bb2470d5ba77 100644 --- a/static/app/components/commandPalette/context.tsx +++ b/static/app/components/commandPalette/context.tsx @@ -11,6 +11,7 @@ import {uuid4} from '@sentry/core'; import {slugify} from 'sentry/utils/slugify'; import {unreachable} from 'sentry/utils/unreachable'; +import {CMDKProvider} from './ui/cmdk'; import {CommandPaletteStateProvider} from './ui/commandPaletteStateContext'; import type {CommandPaletteAction, CommandPaletteActionWithKey} from './types'; @@ -151,7 +152,9 @@ export function CommandPaletteProvider({children}: CommandPaletteProviderProps) return ( - {children} + + {children} + ); diff --git a/static/app/components/commandPalette/globalActions.tsx b/static/app/components/commandPalette/globalActions.tsx new file mode 100644 index 00000000000000..7b070b3d63d234 --- /dev/null +++ b/static/app/components/commandPalette/globalActions.tsx @@ -0,0 +1,416 @@ +import {Fragment, useState} from 'react'; +import {SentryGlobalSearch} from '@sentry-internal/global-search'; +import DOMPurify from 'dompurify'; + +import {ProjectAvatar} from '@sentry/scraps/avatar'; + +import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {openInviteMembersModal} from 'sentry/actionCreators/modal'; +import type { + CMDKQueryOptions, + CommandPaletteAsyncResult, +} from 'sentry/components/commandPalette/types'; +import { + DSN_PATTERN, + getDsnNavTargets, +} from 'sentry/components/search/sources/dsnLookupUtils'; +import type {DsnLookupResponse} from 'sentry/components/search/sources/dsnLookupUtils'; +import { + IconAdd, + IconCompass, + IconDashboard, + IconDiscord, + IconDocs, + IconGithub, + IconGraph, + IconIssues, + IconLock, + IconOpen, + IconPanel, + IconSearch, + IconSettings, + IconStar, + IconUser, +} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {queryOptions} from 'sentry/utils/queryClient'; +import {useMutateUserOptions} from 'sentry/utils/useMutateUserOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useProjects} from 'sentry/utils/useProjects'; +import {useGetStarredDashboards} from 'sentry/views/dashboards/hooks/useGetStarredDashboards'; +import {AGENTS_LANDING_SUB_PATH} from 'sentry/views/insights/pages/agents/settings'; +import {BACKEND_LANDING_SUB_PATH} from 'sentry/views/insights/pages/backend/settings'; +import {FRONTEND_LANDING_SUB_PATH} from 'sentry/views/insights/pages/frontend/settings'; +import {MCP_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mcp/settings'; +import {MOBILE_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mobile/settings'; +import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; +import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; +import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; +import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; + +import {CMDKAction, CMDKGroup} from './ui/cmdk'; + +const DSN_ICONS: React.ReactElement[] = [ + , + , +]; + +const helpSearch = new SentryGlobalSearch(['docs', 'develop']); + +function dsnLookupResource(organizationSlug: string) { + return (query: string): CMDKQueryOptions => + queryOptions({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/dsn-lookup/', + { + path: {organizationIdOrSlug: organizationSlug}, + query: {dsn: query}, + staleTime: 30_000, + } + ), + enabled: DSN_PATTERN.test(query), + select: data => + getDsnNavTargets(data.json).map((target, i) => ({ + to: target.to, + display: { + label: target.label, + details: target.description, + icon: DSN_ICONS[i], + }, + keywords: [query], + })), + }); +} + +function helpSearchResource(search: SentryGlobalSearch) { + return (query: string): CMDKQueryOptions => + queryOptions({ + queryKey: ['command-palette-help-search', query, search], + queryFn: () => + search.query( + query, + {searchAllIndexes: true}, + {analyticsTags: ['source:command-palette']} + ), + select: data => { + const results: CommandPaletteAsyncResult[] = []; + for (const index of data) { + for (const hit of index.hits.slice(0, 3)) { + results.push({ + display: { + label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), + details: DOMPurify.sanitize( + hit.context?.context1 ?? hit.context?.context2 ?? '', + {ALLOWED_TAGS: []} + ), + icon: , + }, + keywords: [hit.context?.context1, hit.context?.context2].filter( + (v): v is string => typeof v === 'string' + ), + onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + }); + } + } + return results; + }, + }); +} + +function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { + if ('to' in item) { + return ; + } + if ('onAction' in item) { + return ; + } + return null; +} + +/** + * Registers globally-available actions into the CMDK collection via JSX. + * Must be mounted inside CMDKProvider (which requires CommandPaletteStateProvider). + * Runs in parallel with the old useGlobalCommandPaletteActions hook during Step 4. + */ +export function GlobalCommandPaletteActions() { + const organization = useOrganization(); + const hasDsnLookup = organization.features.includes('cmd-k-dsn-lookup'); + const {projects} = useProjects(); + const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); + const {starredViews} = useStarredIssueViews(); + const {data: starredDashboards = []} = useGetStarredDashboards(); + const {view, setView} = useSecondaryNavigation(); + const isNavCollapsed = view !== 'expanded'; + const [search] = useState(() => helpSearch); + + const slug = organization.slug; + const prefix = `/organizations/${slug}`; + + return ( + + {/* ── Navigation ── */} + + }}> + + {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( + + ))} + + + {starredViews.map(starredView => ( + }} + to={`${prefix}/issues/views/${starredView.id}/`} + /> + ))} + + + }}> + + {organization.features.includes('ourlogs-enabled') && ( + + )} + + {organization.features.includes('profiling') && ( + + )} + {organization.features.includes('session-replay-ui') && ( + + )} + + + + + }}> + + }}> + {starredDashboards.map(dashboard => ( + }} + to={`${prefix}/dashboard/${dashboard.id}/`} + /> + ))} + + + + {organization.features.includes('performance-view') && ( + }}> + + + + + + + {organization.features.includes('uptime') && ( + + )} + + + )} + + }}> + {getUserOrgNavigationConfiguration().flatMap(section => + section.items.map(item => ( + + )) + )} + + + }}> + {projects.map(project => ( + , + }} + to={`/settings/${slug}/projects/${project.slug}/`} + /> + ))} + + + + {/* ── Add / Create ── */} + + }} + keywords={[t('add dashboard')]} + to={`${prefix}/dashboards/new/`} + /> + }} + keywords={[t('add alert')]} + to={`${prefix}/issues/alerts/wizard/`} + /> + }} + keywords={[t('add project')]} + to={`${prefix}/projects/new/`} + /> + }} + keywords={[t('team invite')]} + onAction={openInviteMembersModal} + /> + + + {/* ── DSN Lookup ── */} + + }} + keywords={[t('client keys'), t('dsn keys')]} + > + {projects.map(project => ( + , + }} + keywords={[`dsn ${project.name}`, `dsn ${project.slug}`]} + to={`/settings/${slug}/projects/${project.slug}/keys/`} + /> + ))} + + {hasDsnLookup && ( + , + }} + resource={dsnLookupResource(slug)} + > + {(data: CommandPaletteAsyncResult[]) => + data.map((item, i) => renderAsyncResult(item, i)) + } + + )} + + + {/* ── Help ── */} + + }} + onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + /> + }} + onAction={() => + window.open('https://discord.gg/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') + } + /> + + {(data: CommandPaletteAsyncResult[]) => + data.map((item, i) => renderAsyncResult(item, i)) + } + + + + {/* ── Interface ── */} + + , + }} + onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} + /> + }}> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'system'}); + addSuccessMessage(t('Theme preference saved: System')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'light'}); + addSuccessMessage(t('Theme preference saved: Light')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'dark'}); + addSuccessMessage(t('Theme preference saved: Dark')); + }} + /> + + + + ); +} diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 9fd7ba72282783..049165a08f7b1c 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -1,4 +1,5 @@ -import type {ReactNode} from 'react'; +import {createContext, useContext, type ReactNode} from 'react'; +import {useQuery} from '@tanstack/react-query'; import type { CMDKQueryOptions, @@ -6,6 +7,7 @@ import type { } from 'sentry/components/commandPalette/types'; import {makeCollection} from './collection'; +import {useCommandPaletteState} from './commandPaletteStateContext'; // --------------------------------------------------------------------------- // Types @@ -36,6 +38,39 @@ export type CMDKActionData = export const CMDKCollection = makeCollection(); +// --------------------------------------------------------------------------- +// Query context +// --------------------------------------------------------------------------- + +/** + * Propagates the current command palette search query to async CMDKGroup nodes + * so they can call resource(query) to fetch results. + */ +export const CMDKQueryContext = createContext(''); + +// --------------------------------------------------------------------------- +// CMDKProvider +// --------------------------------------------------------------------------- + +interface CMDKProviderProps { + children: ReactNode; +} + +/** + * Root provider for the CMDK collection. Must be mounted inside + * CommandPaletteStateProvider because it reads the current query from it. + * + * Use this instead of CMDKCollection.Provider directly. + */ +export function CMDKProvider({children}: CMDKProviderProps) { + const {query} = useCommandPaletteState(); + return ( + + {children} + + ); +} + // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- @@ -55,12 +90,23 @@ type CMDKActionProps = * Registers a node in the collection and propagates its key to children via * GroupContext so they register as its children. * + * When a `resource` prop is provided, fetches data using the current query and + * passes results to a render-prop children function. + * * Does not render any UI — rendering is handled by a separate consumer of the * collection store. */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { const key = CMDKCollection.useRegisterNode({display, keywords, resource}); - const resolvedChildren = typeof children === 'function' ? null : children; + const query = useContext(CMDKQueryContext); + + const {data} = useQuery({ + ...(resource ? resource(query) : {queryKey: [], queryFn: () => null}), + enabled: !!resource, + }); + + const resolvedChildren = + typeof children === 'function' ? (data ? children(data) : null) : children; return ( diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 3ed8dc327e9371..691476a3c295d3 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react'; +import {Fragment, useCallback} from 'react'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -25,17 +25,52 @@ jest.mock('@tanstack/react-virtual', () => ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context'; -import type { - CommandPaletteAction, - CommandPaletteActionWithKey, -} from 'sentry/components/commandPalette/types'; +import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import {CMDKAction, CMDKGroup} 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 {useNavigate} from 'sentry/utils/useNavigate'; -function RegisterActions({actions}: {actions: CommandPaletteAction[]}) { - useCommandPaletteActionsRegister(actions); - return null; +/** + * Converts the old-style CommandPaletteAction[] fixture format into the new + * JSX registration components so tests don't need to be fully rewritten. + */ +function ActionsToJSX({actions}: {actions: CommandPaletteAction[]}) { + return ( + + {actions.map((action, i) => { + if ('actions' in action) { + return ( + + + + ); + } + if ('to' in action) { + return ( + + ); + } + if ('onAction' in action) { + return ( + + ); + } + return null; + })} + + ); } function GlobalActionsComponent({ @@ -48,13 +83,11 @@ function GlobalActionsComponent({ const navigate = useNavigate(); const handleAction = useCallback( - (action: CommandPaletteActionWithKey) => { + (action: CollectionTreeNode) => { if ('to' in action) { - navigate(action.to); + navigate(String(action.to)); } else if ('onAction' in action) { action.onAction(); - } else { - // @TODO: implement async actions } closeModal(); }, @@ -63,7 +96,7 @@ function GlobalActionsComponent({ return ( - + {children} diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index e475ff60531bc5..1a5340416399de 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -19,11 +19,9 @@ import {InnerWrap} from '@sentry/scraps/menuListItem'; import type {MenuListItemProps} from '@sentry/scraps/menuListItem'; import {Text} from '@sentry/scraps/text'; -import {useCommandPaletteActions} from 'sentry/components/commandPalette/context'; -import type { - CMDKQueryOptions, - CommandPaletteActionWithKey, -} from 'sentry/components/commandPalette/types'; +import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; +import {CMDKCollection} from 'sentry/components/commandPalette/ui/cmdk'; +import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import { useCommandPaletteDispatch, useCommandPaletteState, @@ -34,7 +32,6 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconArrow, IconClose, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; -import {useQueries} from 'sentry/utils/queryClient'; import {fzf} from 'sentry/utils/search/fzf'; import type {Theme} from 'sentry/utils/theme'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; @@ -62,17 +59,17 @@ type CommandPaletteActionMenuItem = MenuListItemProps & { hideCheck?: boolean; }; -type CommandPaletteActionWithListItemType = CommandPaletteActionWithKey & { +type CMDKFlatItem = CollectionTreeNode & { listItemType: 'action' | 'section'; }; interface CommandPaletteProps { - onAction: (action: CommandPaletteActionWithKey) => void; + onAction: (action: CollectionTreeNode) => void; } export function CommandPalette(props: CommandPaletteProps) { const theme = useTheme(); - const allActions = useCommandPaletteActions(); + const store = CMDKCollection.useStore(); const state = useCommandPaletteState(); const dispatch = useCommandPaletteDispatch(); @@ -83,67 +80,23 @@ export function CommandPalette(props: CommandPaletteProps) { preload(errorIllustration, {as: 'image'}); } - const root: CommandPaletteActionWithKey = useMemo(() => { - return { - ...state.action?.value.action, - key: 'virtual-root', - actions: - state.action?.value.action && 'actions' in state.action.value.action - ? [...state.action.value.action.actions] - : [...allActions], - - display: { - label: state.action?.value.action?.display.label ?? '', - icon: state.action?.value.action?.display.icon ?? undefined, - ...state.action?.value.action?.display, - }, - }; - }, [state.action, allActions]); - - const [resourceActions, queries] = useMemo(() => { - const actions = collectResourceActions(root, !!state.query); - return [actions, actions.map(({resource}) => resource(state.query))]; - }, [root, state.query]); - - const asyncQueries = useQueries({ - // Queries needs to be stable - queries, - }); - - const asyncChildrenMap = useMemo(() => { - if (asyncQueries.some(query => query.isFetching)) { - return new Map(); - } - - const map = new Map(); - resourceActions.forEach(({key}, i) => { - const data = asyncQueries[i]?.data; - if (data?.length) { - map.set( - key, - data.map( - (action, j) => - ({...action, key: `${key}:async:${j}`}) as CommandPaletteActionWithKey - ) - ); - } - }); - return map; - }, [resourceActions, asyncQueries]); + // The current navigation root: null = top-level, otherwise the key of the + // group the user has drilled into. + const currentRootKey = state.action?.value.key ?? null; + const currentNodes = store.tree(currentRootKey); - const actions = useMemo(() => { + const actions = useMemo(() => { if (!state.query) { - return flattenActions(root, null, asyncChildrenMap); + return flattenActions(currentNodes, null); } const scores = new Map< - CommandPaletteActionWithKey['key'], - {action: CommandPaletteActionWithKey; score: {matched: boolean; score: number}} + string, + {node: CollectionTreeNode; score: {matched: boolean; score: number}} >(); - - scoreTree(root, scores, state.query.toLowerCase(), asyncChildrenMap); - return flattenActions(root, scores, asyncChildrenMap); - }, [root, state.query, asyncChildrenMap]); + scoreTree(currentNodes, scores, state.query.toLowerCase()); + return flattenActions(currentNodes, scores); + }, [currentNodes, state.query]); const analytics = useCommandPaletteAnalytics(actions.length); @@ -246,7 +199,7 @@ export function CommandPalette(props: CommandPaletteProps) { }); const onActionSelection = useCallback( - (key: ReturnType | null) => { + (key: string | number | null) => { const action = actions.find(a => a.key === key); if (!action) { return; @@ -254,9 +207,9 @@ export function CommandPalette(props: CommandPaletteProps) { const resultIndex = actions.indexOf(action); - if ('actions' in action) { + if (action.children.length > 0) { analytics.recordGroupAction(action, resultIndex); - dispatch({type: 'push action', action}); + dispatch({type: 'push action', key: action.key, label: action.display.label}); return; } @@ -264,14 +217,12 @@ export function CommandPalette(props: CommandPaletteProps) { dispatch({type: 'trigger action'}); props.onAction(action); }, - [actions, analytics, dispatch, props, treeState] + [actions, analytics, dispatch, props] ); const debouncedQuery = useDebouncedValue(state.query, 300); - const isLoading = - state.query.length > 0 && - (debouncedQuery !== state.query || asyncQueries.some(query => query.isFetching)); + const isLoading = state.query.length > 0 && debouncedQuery !== state.query; return ( @@ -325,8 +276,8 @@ export function CommandPalette(props: CommandPaletteProps) { value={state.query} aria-label={t('Search commands')} placeholder={ - state.action?.value.action.display.label - ? t('Search inside %s...', state.action.value.action.display.label) + state.action?.value.label + ? t('Search inside %s...', state.action.value.label) : t('Search for commands...') } {...mergeProps(collectionProps, { @@ -385,9 +336,7 @@ export function CommandPalette(props: CommandPaletteProps) { }} - {treeState.collection.size === 0 && - // Don't show no results if we're still fetching data - asyncQueries.every(query => !query.isFetching) ? ( + {treeState.collection.size === 0 ? ( ) : ( )} - - - - - - ); } -interface ItemProps { - children?: React.ReactNode; - name?: string; -} - -function Group(props: ItemProps) { - return props.children; -} - -function Item(props: ItemProps) { - return props.children; -} - -// JSX traversal - -function Collection({children}: {children: React.ReactNode}) { - useLayoutEffect(() => {}, [children]); - return children; -} - -function collectResourceActions( - root: CommandPaletteActionWithKey, - isSearching: boolean -): Array<{key: string; resource: (query: string) => CMDKQueryOptions}> { - const result: Array<{key: string; resource: (query: string) => CMDKQueryOptions}> = []; - - if (isSearching) { - function dfs(node: CommandPaletteActionWithKey) { - if ('resource' in node) { - result.push({key: node.key, resource: node.resource}); - } - if ('actions' in node) { - for (const child of node.actions) { - dfs(child); - } - } - } - dfs(root); - return result; - } - - // Browse mode mirrors the flattenActions no-query path: root's children + their children - for (const action of 'actions' in root ? root.actions : [root]) { - if ('resource' in action) { - result.push({key: action.key, resource: action.resource}); - } - if ('actions' in action) { - for (const child of action.actions) { - if ('resource' in child) { - result.push({key: child.key, resource: child.resource}); - } - } - } - } - - return result; -} - -function score( +function scoreNode( query: string, - action: CommandPaletteActionWithKey + node: CollectionTreeNode ): {matched: boolean; score: number} { - const label = typeof action.display.label === 'string' ? action.display.label : ''; - const details = - typeof action.display.details === 'string' ? action.display.details : ''; - const keywords = action.keywords ?? []; - + const label = node.display.label; + const details = node.display.details ?? ''; + const keywords = node.keywords ?? []; const candidates = [label, details, ...keywords].join(' '); const result = fzf(candidates, query, false); return {matched: result.end !== -1, score: result.score}; } function scoreTree( - root: CommandPaletteActionWithKey, + nodes: Array>, scores: Map< - CommandPaletteActionWithKey['key'], - {action: CommandPaletteActionWithKey; score: {matched: boolean; score: number}} + string, + {node: CollectionTreeNode; score: {matched: boolean; score: number}} >, - query: string, - asyncChildrenMap: Map + query: string ): void { - function dfs(node: CommandPaletteActionWithKey) { - if ('actions' in node) { - for (const action of node.actions) { - dfs(action); - } - } - - for (const child of asyncChildrenMap.get(node.key) ?? []) { + function dfs(node: CollectionTreeNode) { + for (const child of node.children) { dfs(child); } - - const scoreValue = score(query, node); - if (scoreValue.matched) { - scores.set(node.key, {action: node, score: scoreValue}); + const s = scoreNode(query, node); + if (s.matched) { + scores.set(node.key, {node, score: s}); } } - - dfs(root); + for (const node of nodes) { + dfs(node); + } } function flattenActions( - root: CommandPaletteActionWithKey, + nodes: Array>, scores: Map< - CommandPaletteActionWithKey['key'], - {action: CommandPaletteActionWithKey; score: {matched: boolean; score: number}} - > | null, - asyncChildrenMap: Map -): CommandPaletteActionWithListItemType[] { - const results: CommandPaletteActionWithListItemType[] = []; - + string, + {node: CollectionTreeNode; score: {matched: boolean; score: number}} + > | null +): CMDKFlatItem[] { + // Browse mode: show each top-level node and its direct children. if (!scores) { - for (const action of 'actions' in root ? root.actions : [root]) { - results.push({ - ...action, - listItemType: 'actions' in action ? 'section' : 'action', - }); - - const asyncChildren = asyncChildrenMap.get(action.key) ?? []; - - if ('actions' in action) { - for (const child of [...action.actions, ...asyncChildren]) { - results.push({...child, listItemType: 'action'}); - } - } else { - for (const child of asyncChildren) { + const results: CMDKFlatItem[] = []; + for (const node of nodes) { + const isGroup = node.children.length > 0; + results.push({...node, listItemType: isGroup ? 'section' : 'action'}); + if (isGroup) { + for (const child of node.children) { results.push({...child, listItemType: 'action'}); } } } - return results; } - const groups: CommandPaletteActionWithListItemType[] = []; + // Search mode: DFS all nodes, collect as flat list, sort groups by max child + // score, then filter to only matched items. + const collected: CMDKFlatItem[] = []; - function dfs(node: CommandPaletteActionWithKey) { - if ('actions' in node) { - groups.push({...node, listItemType: 'section'}); - for (const action of node.actions) { - dfs(action); + function dfs(node: CollectionTreeNode) { + const isGroup = node.children.length > 0; + collected.push({...node, listItemType: isGroup ? 'section' : 'action'}); + if (isGroup) { + for (const child of node.children) { + dfs(child); } - } else { - groups.push({...node, listItemType: 'action'}); - } - - for (const child of asyncChildrenMap.get(node.key) ?? []) { - dfs(child); } } + for (const node of nodes) { + dfs(node); + } - dfs(root); - - groups.sort((a, b) => { - let aScore = 0; - let bScore = 0; - if ('actions' in a) { - aScore = Math.max( - 0, - ...a.actions.map(action => scores?.get(action.key)?.score.score ?? 0), - ...(asyncChildrenMap.get(a.key) ?? []).map( - action => scores?.get(action.key)?.score.score ?? 0 - ) - ); - } - if ('actions' in b) { - bScore = Math.max( - 0, - ...b.actions.map(action => scores?.get(action.key)?.score.score ?? 0), - ...(asyncChildrenMap.get(b.key) ?? []).map( - action => scores?.get(action.key)?.score.score ?? 0 - ) - ); - } - return bScore - aScore; + // Sort top-level groups to the front by their max-scoring child. + collected.sort((a, b) => { + const maxScore = (n: CMDKFlatItem) => + n.children.length > 0 + ? Math.max(0, ...n.children.map(c => scores.get(c.key)?.score.score ?? 0)) + : 0; + return maxScore(b) - maxScore(a); }); - const flattened = groups.flatMap((result): CommandPaletteActionWithListItemType[] => { - if (result.key === 'virtual-root') { - return []; - } - if ('actions' in result) { - const matchedStaticChildren = result.actions.filter( - action => scores?.get(action.key)?.score.matched - ); - const matchedAsyncChildren = (asyncChildrenMap.get(result.key) ?? []).filter( - action => scores?.get(action.key)?.score.matched - ); - const allMatchedChildren = [...matchedStaticChildren, ...matchedAsyncChildren]; - - if (!allMatchedChildren.length) { - return []; - } - + const flattened = collected.flatMap((item): CMDKFlatItem[] => { + if (item.children.length > 0) { + const matched = item.children.filter(c => scores.get(c.key)?.score.matched); + if (!matched.length) return []; return [ - // Suffix the section header key so that a group appearing here as a - // header AND as an action item inside its parent doesn't produce a - // React duplicate-key error (both entries would otherwise share the - // same key). - {...result, key: `${result.key}:header`, listItemType: 'section'}, - ...allMatchedChildren - .sort((a, b) => { - if (!a || !b) { - return 0; - } - return ( - (scores?.get(b.key)?.score.score ?? 0) - - (scores?.get(a.key)?.score.score ?? 0) - ); - }) - .map(action => ({ - ...action, - listItemType: 'action' as const, - })), + // Suffix the header key so a group used as both a section header and + // an action item inside its parent doesn't produce duplicate React keys. + {...item, key: `${item.key}:header`, listItemType: 'section'}, + ...matched + .sort( + (a, b) => + (scores.get(b.key)?.score.score ?? 0) - + (scores.get(a.key)?.score.score ?? 0) + ) + .map(c => ({...c, listItemType: 'action' as const})), ]; } - return scores?.get(result.key)?.score.matched - ? [{...result, listItemType: 'action'}] - : []; + return scores.get(item.key)?.score.matched ? [{...item, listItemType: 'action'}] : []; }); const seen = new Set(); @@ -649,9 +473,7 @@ function flattenActions( }); } -function makeMenuItemFromAction( - action: CommandPaletteActionWithKey -): CommandPaletteActionMenuItem { +function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem { return { key: action.key, label: action.display.label, @@ -669,7 +491,7 @@ function makeMenuItemFromAction( {action.display.icon} ), - children: 'actions' in action ? action.actions.map(makeMenuItemFromAction) : [], + children: [], hideCheck: true, }; } diff --git a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx index 2ed99e8324520a..b50c37a1de0ae7 100644 --- a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx @@ -6,17 +6,21 @@ import { openCommandPaletteDeprecated, toggleCommandPalette, } from 'sentry/actionCreators/modal'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; import {unreachable} from 'sentry/utils/unreachable'; import {useOrganization} from 'sentry/utils/useOrganization'; -export type LinkedList = { - previous: LinkedList | null; - value: {action: CommandPaletteActionWithKey; query: string}; +/** + * A stack entry for navigating into a CMDK group. Stores the group's + * collection key and display label so the palette can render the correct + * subtree and placeholder text without holding on to the full action object. + */ +export type CMDKNavStack = { + previous: CMDKNavStack | null; + value: {key: string; label: string; query: string}; }; export type CommandPaletteState = { - action: LinkedList | null; + action: CMDKNavStack | null; input: React.RefObject; open: boolean; query: string; @@ -28,7 +32,7 @@ export type CommandPaletteAction = | {type: 'toggle modal'} | {type: 'reset'} | {query: string; type: 'set query'} - | {action: CommandPaletteActionWithKey; type: 'push action'} + | {key: string; label: string; type: 'push action'} | {type: 'trigger action'} | {type: 'pop action'}; @@ -59,7 +63,7 @@ function commandPaletteReducer( return { ...state, action: { - value: {action: action.action, query: state.query}, + value: {key: action.key, label: action.label, query: state.query}, previous: state.action, }, query: '', @@ -150,11 +154,7 @@ export function getActionPath(state: CommandPaletteState): string { const path: string[] = []; let node = state.action; while (node !== null) { - const label = - typeof node.value.action.display.label === 'string' - ? node.value.action.display.label - : ''; - path.unshift(label); + path.unshift(node.value.label); node = node.previous; } return path.join(' → '); diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index fd0358a2449e84..2c8cd41c7c2a86 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -3,7 +3,8 @@ import {css} from '@emotion/react'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {closeModal} from 'sentry/actionCreators/modal'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; +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 {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; @@ -13,13 +14,11 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { const navigate = useNavigate(); const handleSelect = useCallback( - (action: CommandPaletteActionWithKey) => { + (action: CollectionTreeNode) => { if ('to' in action) { - navigate(normalizeUrl(action.to)); + navigate(normalizeUrl(String(action.to))); } else if ('onAction' in action) { action.onAction(); - } else { - // @TODO: handle async actions } closeModal(); }, diff --git a/static/app/components/commandPalette/ui/poc.spec.tsx b/static/app/components/commandPalette/ui/poc.spec.tsx index 46911be860b65b..b97dfe62eab7c3 100644 --- a/static/app/components/commandPalette/ui/poc.spec.tsx +++ b/static/app/components/commandPalette/ui/poc.spec.tsx @@ -69,12 +69,13 @@ describe('Collection', () => { const tree = storeRef.current!.tree(); expect(tree).toHaveLength(1); - const [titleGroup] = tree; + const titleGroup = tree[0]!; expect(titleGroup.name).toBe('Title'); expect(titleGroup.parent).toBeNull(); expect(titleGroup.children).toHaveLength(2); - const [item1, nestedGroup] = titleGroup.children; + const item1 = titleGroup.children[0]!; + const nestedGroup = titleGroup.children[1]!; expect(item1.name).toBe('Item 1'); expect(item1.children).toHaveLength(0); expect(nestedGroup.name).toBe('Item 2'); @@ -132,7 +133,7 @@ describe('Collection', () => { ); - const [groupA] = storeRef.current!.tree(); + const groupA = storeRef.current!.tree()[0]!; const subtree = storeRef.current!.tree(groupA.key); expect(subtree.map(n => n.name)).toEqual(['A1', 'A2']); }); @@ -177,7 +178,7 @@ describe('Collection', () => { ); - const groupBKey = storeRef.current!.tree()[1].key; + const groupBKey = storeRef.current!.tree()[1]!.key; rerender( @@ -239,7 +240,7 @@ describe('Collection', () => { ); - expect(storeRef.current!.tree()[0].name).toBe('updated'); + expect(storeRef.current!.tree()[0]!.name).toBe('updated'); }); it('propagates parent keys correctly at 3+ levels of nesting', () => { @@ -258,16 +259,16 @@ describe('Collection', () => { ); - const [l1] = storeRef.current!.tree(); + const l1 = storeRef.current!.tree()[0]!; expect(l1.parent).toBeNull(); - const [l2] = l1.children; + const l2 = l1.children[0]!; expect(l2.parent).toBe(l1.key); - const [l3] = l2.children; + const l3 = l2.children[0]!; expect(l3.parent).toBe(l2.key); - const [deep] = l3.children; + const deep = l3.children[0]!; expect(deep.parent).toBe(l3.key); expect(deep.name).toBe('deep'); }); diff --git a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx index 44ad6b175064c0..915b306f9ada28 100644 --- a/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx +++ b/static/app/components/commandPalette/useCommandPaletteAnalytics.tsx @@ -2,16 +2,15 @@ import {useEffect, useMemo, useRef} from 'react'; import * as Sentry from '@sentry/react'; import uniqueId from 'lodash/uniqueId'; -import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types'; import { getActionPath, - type LinkedList, + type CMDKNavStack, useCommandPaletteState, } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; -function getLinkedListDepth(node: LinkedList | null): number { +function getNavDepth(node: CMDKNavStack | null): number { let depth = 0; let current = node; while (current !== null) { @@ -30,13 +29,14 @@ function getLinkedListDepth(node: LinkedList | null): number { * Returns `recordAction` and `recordGroupAction` callbacks for action * selections which can't be observed from state alone. */ +interface ActionLike { + display: {label: string}; + to?: unknown; +} + export function useCommandPaletteAnalytics(filteredActionCount: number): { - recordAction: ( - action: CommandPaletteActionWithKey, - resultIndex: number, - group: string - ) => void; - recordGroupAction: (action: CommandPaletteActionWithKey, resultIndex: number) => void; + recordAction: (action: ActionLike, resultIndex: number, group: string) => void; + recordGroupAction: (action: ActionLike, resultIndex: number) => void; } { const organization = useOrganization(); const state = useCommandPaletteState(); @@ -89,10 +89,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { s.prevFilteredCount = filteredActionCount; if (filteredActionCount === 0 && wasNonZero && state.query.length > 0) { - const actionLabel = - typeof state.action?.value.action.display.label === 'string' - ? state.action.value.action.display.label - : undefined; + const actionLabel = state.action?.value.label; trackAnalytics('command_palette.no_results', { organization, query: state.query, @@ -135,11 +132,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { return useMemo( () => ({ - recordAction( - action: CommandPaletteActionWithKey, - resultIndex: number, - group: string - ) { + recordAction(action: ActionLike, resultIndex: number, group: string) { const s = analyticsState.current; const path = getActionPath(s.state); const label = @@ -159,7 +152,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { s.actionsSelected++; s.completed = true; }, - recordGroupAction(action: CommandPaletteActionWithKey, resultIndex: number) { + recordGroupAction(action: ActionLike, resultIndex: number) { const s = analyticsState.current; trackAnalytics('command_palette.action_selected', { @@ -175,7 +168,7 @@ export function useCommandPaletteAnalytics(filteredActionCount: number): { s.hadInteraction = true; s.actionsSelected++; - const depth = getLinkedListDepth(s.state.action) + 1; + const depth = getNavDepth(s.state.action) + 1; if (depth > s.maxDrillDepth) { s.maxDrillDepth = depth; } diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 30e423e7794410..6794cf7051c777 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -5,6 +5,7 @@ import {useHotkeys} from '@sentry/scraps/hotkey'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; +import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/globalActions'; import {CommandPaletteHotkeys} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useGlobalCommandPaletteActions} from 'sentry/components/commandPalette/useGlobalCommandPaletteActions'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; @@ -50,6 +51,7 @@ function UserAndOrganizationNavigation() { return ( + {layout === 'mobile' ? ( From c06aae59fdf003364191a276206321b41c3f24b2 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 08:55:18 -0700 Subject: [PATCH 03/43] ref(cmdk): pre-review cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove CMDK_PLAN.md (internal planning doc, not production code) - Rename poc.spec.tsx → collection.spec.tsx (no longer a PoC) - Remove dead useGlobalCommandPaletteActions() call from navigation — commandPalette.tsx now reads from CMDKCollection.useStore(), so the old context registration had no effect - Rewrite stories demo to use CMDKAction/CMDKGroup JSX API; the old useCommandPaletteActionsRegister path fed CommandPaletteActionsContext which nothing reads anymore, so the demo was showing an empty palette Co-Authored-By: Claude Sonnet 4 --- .../components/commandPalette/CMDK_PLAN.md | 194 ------------------ .../commandPalette/__stories__/components.tsx | 44 ++-- .../ui/{poc.spec.tsx => collection.spec.tsx} | 0 static/app/views/navigation/index.tsx | 2 - 4 files changed, 12 insertions(+), 228 deletions(-) delete mode 100644 static/app/components/commandPalette/CMDK_PLAN.md rename static/app/components/commandPalette/ui/{poc.spec.tsx => collection.spec.tsx} (100%) diff --git a/static/app/components/commandPalette/CMDK_PLAN.md b/static/app/components/commandPalette/CMDK_PLAN.md deleted file mode 100644 index 01dc617898c148..00000000000000 --- a/static/app/components/commandPalette/CMDK_PLAN.md +++ /dev/null @@ -1,194 +0,0 @@ -# CMDK Migration Plan - -Migrate the command palette action registration system from the reducer-based -`useCommandPaletteActionsRegister` model to a JSX/React-context collection model. - -## Background - -`ui/collection.tsx` exports a `makeCollection()` factory. Each call creates an -isolated, fully-typed collection instance with its own React contexts. The factory -takes a single type parameter `T` — the data shape shared by all nodes. There is no -separate group/item type: a node becomes a group by wrapping its children in -`GroupContext.Provider`, not by carrying a `type` field. - -The factory returns: - -```ts -{ - Provider, // root store provider - GroupContext, // string context — propagates nearest parent group key - useStore(), // returns CollectionStore with register/unregister/tree() - useRegisterNode(data: T): string, // registers a node, returns its stable key -} -``` - -`tree()` returns `CollectionTreeNode[]` where each node is: - -```ts -{ key: string; parent: string | null; children: CollectionTreeNode[] } & T -``` - -Data fields are spread directly onto the node — no `.data` wrapper. A node is a -group if `node.children.length > 0`. - -On top of this, `ui/cmdk.tsx` defines: - -```ts -// Single data shape — groups are nodes that happen to have children -export type CMDKActionData = - | { display: DisplayProps; to: string; keywords?: string[] } - | { display: DisplayProps; onAction: () => void; keywords?: string[] } - | { display: DisplayProps; resource?: (...) => CMDKQueryOptions; keywords?: string[] }; - -export const CMDKCollection = makeCollection(); -``` - -`CMDKGroup` calls `CMDKCollection.useRegisterNode(data)` and wraps children in -`CMDKCollection.GroupContext.Provider`. `CMDKAction` calls `CMDKCollection.useRegisterNode(data)` -and renders `null`. - -The palette consumes `CMDKCollection.useStore().tree()` instead of `useCommandPaletteActions()`. - -## Todo - -### Step 1 — Extend the data model ✅ Done - -The collection factory, typed data shapes, `CMDKGroup`, and `CMDKAction` are all -implemented in `ui/collection.tsx` and `ui/cmdk.tsx`. `poc.spec.tsx` covers -registration, unregistration, and data freshness. - -### Step 2 — Add CMDKQueryContext - -Async groups (`CMDKGroup` with `resource`) need the current search query to call -`resource(query)`. The query lives in `CommandPaletteStateContext` (`state.query`). - -- [x] Add `CMDKQueryContext = createContext('')` to `ui/cmdk.tsx` -- [x] Update `CMDKCollection.Provider` to also provide `CMDKQueryContext`: - ```tsx - function CMDKCollectionProvider({children}) { - const {query} = useCommandPaletteState(); - return ( - - {children} - - ); - } - ``` - Export this as `CMDKProvider` — callers use this instead of `CMDKCollection.Provider` directly. -- [x] In `CMDKGroup`, read `const query = useContext(CMDKQueryContext)` -- [x] When `resource` prop is present, call `useQuery({ ...resource(query), enabled: !!resource })` - inside `CMDKGroup` -- [x] Resolve children based on whether `children` is a render prop: - ```ts - const resolvedChildren = - typeof children === 'function' ? (data ? children(data) : null) : children; - ``` -- [x] Wrap resolved children in `` as before - -### Step 3 — Wire CMDKProvider into the provider tree - -`CMDKProvider` (from Step 2) must sit inside `CommandPaletteStateProvider` because it -reads from `useCommandPaletteState()`. - -- [x] Find where `CommandPaletteProvider` and `CommandPaletteStateProvider` are mounted — - search for `CommandPaletteProvider` in the codebase to locate the mount point -- [x] Place `` as a child of `CommandPaletteStateProvider`, wrapping - whatever subtree currently lives inside it -- [x] Verify no runtime errors — the collection store is live but empty - -### Step 4 — Convert global actions to a JSX component - -`useGlobalCommandPaletteActions` calls `useCommandPaletteActionsRegister([...actions])` -with a large static action tree. Replace it with a component that renders the equivalent -JSX tree. The old hook stays alive during this step so both systems run in parallel. - -- [x] Create `GlobalCommandPaletteActions` component (can live in `useGlobalCommandPaletteActions.tsx` - or a new file `globalActions.tsx`) -- [x] Translate each section — read `useGlobalCommandPaletteActions.tsx` carefully before translating: - - [x] **Navigation** — one `` containing a - `` per destination (Issues, Explore, Dashboards, Insights, Settings) - - [x] **Create** — one `` with `` for Dashboard, - Alert, Project, Invite Members - - [x] **DSN Lookup** — `` with render-prop children: - `{data => data.map(item => )}` - - [x] **Help** — static `` nodes for Docs/Discord/GitHub/Changelog plus a - `` with render-prop children for search results - - [x] **Interface** — `` for navigation toggle and theme switching -- [x] Mount `` inside `` in the provider tree -- [x] Verify `CMDKCollection.useStore().tree()` returns the expected structure by adding a - temporary log or test — do not remove old system yet - -### Step 5 — Update the palette UI to read from the collection store - -`commandPalette.tsx` currently drives all data through `useCommandPaletteActions()` → -`scoreTree()` → `flattenActions()` → `collectResourceActions()`. Replace this pipeline -with the collection store. - -- [ ] Replace `const actions = useCommandPaletteActions()` with - `const store = CMDKCollection.useStore()` in `commandPalette.tsx` -- [ ] Rewrite `scoreTree()` to accept `CollectionTreeNode[]`. Data fields - are spread directly onto nodes — access `node.display.label`, `node.display.details`, - and `node.keywords` directly (no `node.data.*` indirection) -- [ ] Rewrite `flattenActions()` to accept `CollectionTreeNode[]` with the - same direct field access -- [ ] A node is a group if `node.children.length > 0` — replace any `node.type === 'group'` - checks with this -- [ ] Remove `collectResourceActions()` entirely — async fetching is now handled inside - `CMDKGroup` before nodes appear in the tree. The `resource` field never reaches the consumer. -- [ ] Replace the linked-list action stack navigation with the collection's `tree(rootKey)` API: - - When the user navigates into a group, store that group's `key` as the current root - - Call `store.tree(currentRootKey)` to get the subtree to display - - Going back means popping the key stack - - Update `commandPaletteStateContext.tsx` `push action` / `pop action` to store node - keys instead of full action objects -- [ ] Update `modal.tsx` `handleSelect`: `to` and `onAction` are now direct fields on the - node (e.g. `node.to`, `node.onAction`) — no `.data` wrapper, no cast needed -- [ ] Run `CI=true pnpm test static/app/components/commandPalette` and fix failures - -### Step 6 — Remove the old registration infrastructure - -Only do this after Step 5 passes all tests. - -- [ ] Search for all callers of `useCommandPaletteActionsRegister` outside of - `useGlobalCommandPaletteActions.tsx` — these are page-scoped action registrations. - For each one, create a component that renders `` / `` and mount - it in the relevant page's component tree instead. -- [ ] Delete the reducer (`actionsReducer`), `addKeysToActions`, `addKeysToChildActions` - from `context.tsx` -- [ ] Remove `CommandPaletteActionsContext` and `useCommandPaletteActions` from `context.tsx` -- [ ] Remove `CommandPaletteRegistrationContext` and `useCommandPaletteActionsRegister` from `context.tsx` -- [ ] Remove or simplify `CommandPaletteProvider` — if it only wrapped the two contexts above - it can be deleted; if it serves other purposes keep a slimmed version -- [ ] Remove the old `useGlobalCommandPaletteActions` hook (replaced by `GlobalCommandPaletteActions`) -- [ ] Clean up `types.tsx`: remove `CommandPaletteActionWithKey` and its variants - (`CommandPaletteActionLinkWithKey`, `CommandPaletteActionCallbackWithKey`, etc.) — these - were only needed because the old system added keys at registration time. The new system - uses `useId()` inside each component. -- [ ] Run the full test suite and typecheck: `CI=true pnpm test` and `pnpm run typecheck` - -## Key files - -| File | Role | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `ui/collection.tsx` | Generic factory — `makeCollection()` returns `{Provider, GroupContext, useStore, useRegisterNode}` | -| `ui/cmdk.tsx` | CMDK layer — `CMDKActionData`, `CMDKCollection`, `CMDKGroup`, `CMDKAction`, `CMDKProvider` (added in Step 2) | -| `ui/commandPalette.tsx` | Palette UI — reads from collection store, scoring, flattening, keyboard nav | -| `ui/commandPaletteStateContext.tsx` | UI state — query string, open/close, navigation stack | -| `ui/modal.tsx` | Modal wrapper — executes selected actions via `to` or `onAction` | -| `context.tsx` | Old registration system — deleted in Step 6 | -| `useGlobalCommandPaletteActions.tsx` | Global actions — replaced by JSX component in Step 4 | -| `types.tsx` | Shared types — `WithKey` variants removed in Step 6 | - -## Notes - -- Steps 2 and 3 are independent and can be done in parallel. -- Step 6 is only safe once Step 5 is complete and all tests pass. -- Item reordering (items that change position without unmounting) is a known limitation of - the collection model — it is documented and intentionally deferred. Do not block the - migration on it. -- `useId()` inside `CMDKGroup`/`CMDKAction` replaces the old `uuid4()` + slug key generation - in `addKeysToActions`. Keys are now stable across renders but reset on remount. -- There is no `type: 'group' | 'item'` on tree nodes. A node is a group if - `node.children.length > 0`. An empty async group (loading state) will appear as a leaf - until its children mount — handle this at the rendering layer if needed (e.g. check - for `resource` on the node data to show a loading indicator). diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 36b15d1af6df66..7dca37197ff446 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -2,19 +2,13 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; -import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context'; -import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import {CMDKAction, CMDKGroup} 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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; -export function RegisterActions({actions}: {actions: CommandPaletteAction[]}) { - useCommandPaletteActionsRegister(actions); - return null; -} - export function CommandPaletteDemo() { const navigate = useNavigate(); @@ -29,33 +23,19 @@ export function CommandPaletteDemo() { [navigate] ); - const demoActions: CommandPaletteAction[] = [ - { - display: {label: 'Go to Flex story'}, - to: '/stories/layout/flex/', - }, - { - display: {label: 'Execute an action'}, - onAction: () => { - addSuccessMessage('Action executed'); - }, - }, - { - display: {label: 'Parent action'}, - actions: [ - { - display: {label: 'Child action'}, - onAction: () => { - addSuccessMessage('Child action executed'); - }, - }, - ], - }, - ]; - return ( - + + addSuccessMessage('Action executed')} + /> + + addSuccessMessage('Child action executed')} + /> + ); diff --git a/static/app/components/commandPalette/ui/poc.spec.tsx b/static/app/components/commandPalette/ui/collection.spec.tsx similarity index 100% rename from static/app/components/commandPalette/ui/poc.spec.tsx rename to static/app/components/commandPalette/ui/collection.spec.tsx diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 6794cf7051c777..7b9417240fb2b5 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -7,7 +7,6 @@ import {ExternalLink} from '@sentry/scraps/link'; import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/globalActions'; import {CommandPaletteHotkeys} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; -import {useGlobalCommandPaletteActions} from 'sentry/components/commandPalette/useGlobalCommandPaletteActions'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {t} from 'sentry/locale'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -35,7 +34,6 @@ function UserAndOrganizationNavigation() { const {visible} = useGlobalModal(); const {view, setView} = useSecondaryNavigation(); - useGlobalCommandPaletteActions(); const hasPageFrame = useHasPageFrameFeature(); useHotkeys( From a0098775e995abace620e5e4bc280eb6449be386 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:06:44 -0700 Subject: [PATCH 04/43] remove redundant context --- .../app/components/commandPalette/ui/cmdk.tsx | 62 +++---------------- 1 file changed, 8 insertions(+), 54 deletions(-) diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 049165a08f7b1c..7a207fd2bdc085 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -1,4 +1,3 @@ -import {createContext, useContext, type ReactNode} from 'react'; import {useQuery} from '@tanstack/react-query'; import type { @@ -9,14 +8,10 @@ import type { import {makeCollection} from './collection'; import {useCommandPaletteState} from './commandPaletteStateContext'; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - interface DisplayProps { label: string; details?: string; - icon?: ReactNode; + icon?: React.ReactNode; } /** @@ -32,52 +27,18 @@ export type CMDKActionData = resource?: (query: string) => CMDKQueryOptions; }; -// --------------------------------------------------------------------------- -// Typed collection instance for CMDK -// --------------------------------------------------------------------------- - export const CMDKCollection = makeCollection(); - -// --------------------------------------------------------------------------- -// Query context -// --------------------------------------------------------------------------- - -/** - * Propagates the current command palette search query to async CMDKGroup nodes - * so they can call resource(query) to fetch results. - */ -export const CMDKQueryContext = createContext(''); - -// --------------------------------------------------------------------------- -// CMDKProvider -// --------------------------------------------------------------------------- - -interface CMDKProviderProps { - children: ReactNode; -} - /** * Root provider for the CMDK collection. Must be mounted inside * CommandPaletteStateProvider because it reads the current query from it. - * - * Use this instead of CMDKCollection.Provider directly. */ -export function CMDKProvider({children}: CMDKProviderProps) { - const {query} = useCommandPaletteState(); - return ( - - {children} - - ); +export function CMDKProvider({children}: {children: React.ReactNode}) { + return {children}; } -// --------------------------------------------------------------------------- -// Components -// --------------------------------------------------------------------------- - interface CMDKGroupProps { display: DisplayProps; - children?: ReactNode | ((data: CommandPaletteAsyncResult[]) => ReactNode); + children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode); keywords?: string[]; resource?: (query: string) => CMDKQueryOptions; } @@ -88,17 +49,13 @@ type CMDKActionProps = /** * Registers a node in the collection and propagates its key to children via - * GroupContext so they register as its children. - * - * When a `resource` prop is provided, fetches data using the current query and - * passes results to a render-prop children function. - * - * Does not render any UI — rendering is handled by a separate consumer of the - * collection store. + * GroupContext. When a `resource` prop is provided, fetches data using the + * current query and passes results to a render-prop children function. */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { const key = CMDKCollection.useRegisterNode({display, keywords, resource}); - const query = useContext(CMDKQueryContext); + + const {query} = useCommandPaletteState(); const {data} = useQuery({ ...(resource ? resource(query) : {queryKey: [], queryFn: () => null}), @@ -117,9 +74,6 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp /** * Registers a leaf action node in the collection. - * - * Does not render any UI — rendering is handled by a separate consumer of the - * collection store. */ export function CMDKAction(props: CMDKActionProps) { CMDKCollection.useRegisterNode(props); From b821641d8d900444ec062eeb59053e02a41fcb9a Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:18:51 -0700 Subject: [PATCH 05/43] ref(cmdk): remove useGlobalCommandPaletteActions GlobalCommandPaletteActions covers everything the old hook registered. The hook had no remaining callers after the previous commit removed the navigation/index.tsx call site. Also cleans up cmdk.tsx comments and renames GroupContext to Context in the collection factory API. Co-Authored-By: Claude Sonnet 4 --- .../commandPalette/globalActions.tsx | 3 +- .../app/components/commandPalette/ui/cmdk.tsx | 7 +- .../commandPalette/ui/collection.spec.tsx | 12 +- .../commandPalette/ui/collection.tsx | 24 +- .../useGlobalCommandPaletteActions.tsx | 566 ------------------ 5 files changed, 15 insertions(+), 597 deletions(-) delete mode 100644 static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx diff --git a/static/app/components/commandPalette/globalActions.tsx b/static/app/components/commandPalette/globalActions.tsx index 7b070b3d63d234..1a4b49e474841b 100644 --- a/static/app/components/commandPalette/globalActions.tsx +++ b/static/app/components/commandPalette/globalActions.tsx @@ -120,7 +120,7 @@ function helpSearchResource(search: SentryGlobalSearch) { function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { if ('to' in item) { - return ; + return ; } if ('onAction' in item) { return ; @@ -131,7 +131,6 @@ function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { /** * Registers globally-available actions into the CMDK collection via JSX. * Must be mounted inside CMDKProvider (which requires CommandPaletteStateProvider). - * Runs in parallel with the old useGlobalCommandPaletteActions hook during Step 4. */ export function GlobalCommandPaletteActions() { const organization = useOrganization(); diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 7a207fd2bdc085..5f9acc9ab10fd4 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -1,4 +1,5 @@ import {useQuery} from '@tanstack/react-query'; +import type {LocationDescriptor} from 'history'; import type { CMDKQueryOptions, @@ -44,7 +45,7 @@ interface CMDKGroupProps { } type CMDKActionProps = - | {display: DisplayProps; to: string; keywords?: string[]} + | {display: DisplayProps; to: LocationDescriptor; keywords?: string[]} | {display: DisplayProps; onAction: () => void; keywords?: string[]}; /** @@ -66,9 +67,9 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp typeof children === 'function' ? (data ? children(data) : null) : children; return ( - + {resolvedChildren} - + ); } diff --git a/static/app/components/commandPalette/ui/collection.spec.tsx b/static/app/components/commandPalette/ui/collection.spec.tsx index b97dfe62eab7c3..d96af1cfe8ac44 100644 --- a/static/app/components/commandPalette/ui/collection.spec.tsx +++ b/static/app/components/commandPalette/ui/collection.spec.tsx @@ -6,10 +6,6 @@ import {slot} from '@sentry/scraps/slot'; import {makeCollection} from './collection'; -// --------------------------------------------------------------------------- -// Shared test collection + components -// --------------------------------------------------------------------------- - interface NodeData { name: string; } @@ -19,9 +15,9 @@ const TestCollection = makeCollection(); function Group({children, name}: {name: string; children?: React.ReactNode}) { const key = TestCollection.useRegisterNode({name}); return ( - + {children} - + ); } @@ -45,10 +41,6 @@ function makeStoreRef() { > | null>; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('Collection', () => { it('builds the tree from JSX structure', () => { const storeRef = makeStoreRef(); diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index e13eebd928a7c5..fc33a610765d19 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -8,10 +8,6 @@ import { useRef, } from 'react'; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - type StoredNode = { dataRef: React.MutableRefObject; key: string; @@ -41,17 +37,13 @@ export interface CollectionStore { unregister: (key: string) => void; } -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - export interface CollectionInstance { /** * Propagates the nearest parent group's key to children. * Use it in Group components to wrap children so they register under this node: - * + * */ - GroupContext: React.Context; + Context: React.Context; /** * Root provider. Wrap your node tree in this component. @@ -63,7 +55,7 @@ export interface CollectionInstance { * Returns the stable key assigned to this node. * * To make this node a group (i.e. allow it to have children), wrap its - * children in . + * children in . */ useRegisterNode: (data: T) => string; @@ -79,14 +71,14 @@ export interface CollectionInstance { * * There is a single type parameter T — the data shape shared by all nodes. * A node becomes a "group" by virtue of having children registered under it - * (via GroupContext), not by having a separate type. + * (via Context), not by having a separate type. * * @example * const CMDKCollection = makeCollection(); * * function CMDKGroup({ data, children }) { * const key = CMDKCollection.useRegisterNode(data); - * return {children}; + * return {children}; * } * * function CMDKAction({ data }) { @@ -96,7 +88,7 @@ export interface CollectionInstance { */ export function makeCollection(): CollectionInstance { const StoreContext = createContext | null>(null); - const GroupContext = createContext(null); + const Context = createContext(null); // ------------------------------------------------------------------------- // Provider @@ -181,7 +173,7 @@ export function makeCollection(): CollectionInstance { function useRegisterNode(data: T): string { const store = useStore(); - const parentKey = useContext(GroupContext); + const parentKey = useContext(Context); const key = useId(); // Store data in a ref so tree() always reflects the latest value without @@ -198,5 +190,5 @@ export function makeCollection(): CollectionInstance { return key; } - return {Provider, GroupContext, useStore, useRegisterNode}; + return {Provider, Context, useStore, useRegisterNode}; } diff --git a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx deleted file mode 100644 index ac5d3a5406334a..00000000000000 --- a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import {useState} from 'react'; -import {SentryGlobalSearch} from '@sentry-internal/global-search'; -import DOMPurify from 'dompurify'; - -import {ProjectAvatar} from '@sentry/scraps/avatar'; - -import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; -import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context'; -import type { - CMDKQueryOptions, - CommandPaletteAction, - CommandPaletteAsyncResult, -} from 'sentry/components/commandPalette/types'; -import { - DSN_PATTERN, - getDsnNavTargets, -} from 'sentry/components/search/sources/dsnLookupUtils'; -import type {DsnLookupResponse} from 'sentry/components/search/sources/dsnLookupUtils'; -import { - IconAdd, - IconCompass, - IconDashboard, - IconDiscord, - IconDocs, - IconSearch, - IconGithub, - IconGraph, - IconIssues, - IconList, - IconOpen, - IconSettings, - IconStar, - IconUser, - IconPanel, - IconLock, -} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {queryOptions} from 'sentry/utils/queryClient'; -import {useMutateUserOptions} from 'sentry/utils/useMutateUserOptions'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useProjects} from 'sentry/utils/useProjects'; -import {useGetStarredDashboards} from 'sentry/views/dashboards/hooks/useGetStarredDashboards'; -import {AGENTS_LANDING_SUB_PATH} from 'sentry/views/insights/pages/agents/settings'; -import {BACKEND_LANDING_SUB_PATH} from 'sentry/views/insights/pages/backend/settings'; -import {FRONTEND_LANDING_SUB_PATH} from 'sentry/views/insights/pages/frontend/settings'; -import {MCP_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mcp/settings'; -import {MOBILE_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mobile/settings'; -import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; -import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; -import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; -import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; - -const DSN_ICONS: React.ReactElement[] = [ - , - , - , -]; - -// This hook generates actions for all pages in the primary and secondary navigation. -// TODO: Consider refactoring the navigation so that this can read from the same source -// of truth and avoid divergence. - -function useNavigationActions(): CommandPaletteAction[] { - const organization = useOrganization(); - const slug = organization.slug; - const prefix = `/organizations/${slug}`; - const {starredViews} = useStarredIssueViews(); - const {data: starredDashboards = []} = useGetStarredDashboards(); - const {projects} = useProjects(); - - const issuesChildren: CommandPaletteAction[] = [ - { - display: { - label: t('Feed'), - }, - to: `${prefix}/issues/`, - }, - ...Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ({ - display: { - label: config.label, - }, - to: `${prefix}/issues/${config.key}/`, - })), - { - display: { - label: t('User Feedback'), - }, - to: `${prefix}/issues/feedback/`, - }, - { - display: { - label: t('All Views'), - }, - to: `${prefix}/issues/views/`, - }, - ...starredViews.map(view => ({ - display: { - label: view.label, - icon: , - }, - to: `${prefix}/issues/views/${view.id}/`, - })), - ]; - - const exploreChildren: CommandPaletteAction[] = [ - { - display: { - label: t('Traces'), - }, - to: `${prefix}/explore/traces/`, - }, - organization.features.includes('ourlogs-enabled') - ? { - display: { - label: t('Logs'), - }, - to: `${prefix}/explore/logs/`, - } - : undefined, - { - display: { - label: t('Discover'), - }, - to: `${prefix}/explore/discover/homepage/`, - }, - organization.features.includes('profiling') - ? { - display: { - label: t('Profiles'), - }, - to: `${prefix}/explore/profiling/`, - } - : undefined, - organization.features.includes('session-replay-ui') - ? { - display: { - label: t('Replays'), - }, - to: `${prefix}/explore/replays/`, - } - : undefined, - { - display: { - label: t('Releases'), - }, - to: `${prefix}/explore/releases/`, - }, - { - display: { - label: t('All Queries'), - }, - to: `${prefix}/explore/saved-queries/`, - }, - ].filter(action => action !== undefined); - - const dashboardsChildren: CommandPaletteAction[] = [ - { - display: { - label: t('All Dashboards'), - }, - to: `${prefix}/dashboards/`, - }, - { - display: { - label: t('Starred Dashboards'), - icon: , - }, - actions: starredDashboards.map(dashboard => ({ - display: { - label: dashboard.title, - icon: , - }, - to: `${prefix}/dashboard/${dashboard.id}/`, - })), - }, - ]; - - const insightsChildren: CommandPaletteAction[] = [ - { - display: { - label: t('Frontend'), - }, - to: `${prefix}/insights/${FRONTEND_LANDING_SUB_PATH}/`, - }, - { - display: { - label: t('Backend'), - }, - to: `${prefix}/insights/${BACKEND_LANDING_SUB_PATH}/`, - }, - { - display: { - label: t('Mobile'), - }, - to: `${prefix}/insights/${MOBILE_LANDING_SUB_PATH}/`, - }, - { - display: { - label: t('Agents'), - }, - to: `${prefix}/insights/${AGENTS_LANDING_SUB_PATH}/`, - }, - { - display: { - label: t('MCP'), - }, - to: `${prefix}/insights/${MCP_LANDING_SUB_PATH}/`, - }, - { - display: { - label: t('Crons'), - }, - to: `${prefix}/insights/crons/`, - }, - organization.features.includes('uptime') - ? { - display: { - label: t('Uptime'), - }, - to: `${prefix}/insights/uptime/`, - } - : undefined, - { - display: { - label: t('All Projects'), - }, - to: `${prefix}/insights/projects/`, - }, - ].filter(action => action !== undefined); - - return [ - { - display: { - label: t('Go to...'), - }, - actions: [ - { - display: { - label: t('Issues'), - icon: , - }, - actions: issuesChildren, - }, - { - display: { - label: t('Explore'), - icon: , - }, - actions: exploreChildren, - }, - { - display: { - label: t('Dashboards'), - icon: , - }, - actions: dashboardsChildren, - }, - organization.features.includes('performance-view') - ? { - display: { - label: t('Insights'), - icon: , - }, - actions: insightsChildren, - } - : undefined, - { - display: { - label: t('Settings'), - icon: , - }, - actions: getUserOrgNavigationConfiguration().flatMap(item => - item.items.map(settingsChildItem => ({ - display: { - label: settingsChildItem.title, - }, - to: settingsChildItem.path, - })) - ), - }, - { - display: { - label: t('Project Settings'), - icon: , - }, - actions: projects.map(project => ({ - display: { - label: project.name, - icon: , - }, - to: `/settings/${slug}/projects/${project.slug}/`, - })), - }, - ].filter(action => action !== undefined), - }, - ]; -} - -function useNavigationToggleCollapsed(): CommandPaletteAction { - const {view, setView} = useSecondaryNavigation(); - const isCollapsed = view !== 'expanded'; - - return { - display: { - label: isCollapsed - ? t('Expand Navigation Sidebar') - : t('Collapse Navigation Sidebar'), - icon: , - }, - onAction: () => { - setView(view === 'expanded' ? 'collapsed' : 'expanded'); - }, - }; -} - -/** - * Registers globally-available actions. Requires that the organization has been loaded. - */ -export function useGlobalCommandPaletteActions() { - const organization = useOrganization(); - const hasDsnLookup = organization.features.includes('cmd-k-dsn-lookup'); - const {projects} = useProjects(); - const navigateActions = useNavigationActions(); - const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); - const navigationToggleAction = useNavigationToggleCollapsed(); - - const [search] = useState(() => new SentryGlobalSearch(['docs', 'develop'])); - - const navPrefix = `/organizations/${organization.slug}`; - - useCommandPaletteActionsRegister([ - ...navigateActions, - // BEGIN ADD ACTIONS - { - display: { - label: t('Add'), - }, - actions: [ - { - display: { - label: t('Create Dashboard'), - icon: , - }, - keywords: [t('add dashboard')], - to: `${navPrefix}/dashboards/new/`, - }, - { - display: { - label: t('Create Alert'), - icon: , - }, - keywords: [t('add alert')], - to: `${navPrefix}/issues/alerts/wizard/`, - }, - { - display: { - label: t('Create Project'), - icon: , - }, - keywords: [t('add project')], - to: `${navPrefix}/projects/new/`, - }, - { - display: { - label: t('Invite Members'), - icon: , - }, - keywords: [t('team invite')], - onAction: openInviteMembersModal, - }, - ], - }, - // END ADD - // BEGIN DSN LOOKUP - { - display: {label: t('DSN')}, - keywords: [t('client keys')], - actions: [ - { - display: { - label: t('Project DSN Keys'), - icon: , - }, - keywords: [t('client keys'), t('dsn keys')], - actions: projects.map(project => ({ - display: { - label: project.name, - icon: , - }, - keywords: [`dsn ${project.name}`, `dsn ${project.slug}`], - to: `/settings/${organization.slug}/projects/${project.slug}/keys/`, - })), - }, - hasDsnLookup - ? { - display: { - label: t('Reverse DSN lookup'), - details: t( - 'Paste a DSN into the search bar to find the project it belongs to.' - ), - icon: , - }, - actions: [], - resource: (query: string): CMDKQueryOptions => { - return queryOptions({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/dsn-lookup/', - { - path: {organizationIdOrSlug: organization.slug}, - query: {dsn: query}, - staleTime: 30_000, - } - ), - enabled: DSN_PATTERN.test(query), - select: data => - getDsnNavTargets(data.json).map((target, i) => ({ - to: target.to, - display: { - label: target.label, - details: target.description, - icon: DSN_ICONS[i], - }, - keywords: [query], - })), - }); - }, - } - : undefined, - ].filter(action => action !== undefined), - }, - // END DSN LOOKUP - // BEGIN HELP ACTIONS - { - display: { - label: t('Help'), - }, - actions: [ - { - display: { - label: t('Open Documentation'), - icon: , - }, - onAction: () => window.open('https://docs.sentry.io', '_blank', 'noreferrer'), - }, - { - display: { - label: t('Join Discord'), - icon: , - }, - onAction: () => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer'), - }, - { - display: { - label: t('Open GitHub Repository'), - icon: , - }, - onAction: () => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer'), - }, - { - display: { - label: t('View Changelog'), - icon: , - }, - onAction: () => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer'), - }, - ], - resource: (query: string) => { - return queryOptions({ - queryKey: ['command-palette-help-search', query, search], - queryFn: () => { - return search.query( - query, - { - searchAllIndexes: true, - }, - { - analyticsTags: ['source:command-palette'], - } - ); - }, - select: data => { - const actions: CommandPaletteAsyncResult[] = []; - - for (const index of data) { - // We'll limit async results to avoid overwhelming the UI - for (const hit of index.hits.slice(0, 3)) { - actions.push({ - display: { - label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), - details: DOMPurify.sanitize( - hit.context?.context1 ?? hit.context?.context2 ?? '', - {ALLOWED_TAGS: []} - ), - icon: , - }, - keywords: [hit.context?.context1, hit.context?.context2].filter( - value => value !== undefined && typeof value === 'string' - ), - onAction: () => { - window.open(hit.url, '_blank', 'noreferrer'); - }, - }); - } - } - - return actions; - }, - }); - }, - }, - // END HELP ACTIONS - // START UI ACTIONS - { - display: { - label: t('Interface'), - }, - actions: [ - navigationToggleAction, - { - display: { - label: t('Change Color Theme'), - icon: , - }, - actions: [ - { - display: { - label: t('System'), - }, - onAction: async () => { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'system'}); - addSuccessMessage(t('Theme preference saved: System')); - }, - }, - { - display: { - label: t('Light'), - }, - onAction: async () => { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'light'}); - addSuccessMessage(t('Theme preference saved: Light')); - }, - }, - { - display: { - label: t('Dark'), - }, - onAction: async () => { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'dark'}); - addSuccessMessage(t('Theme preference saved: Dark')); - }, - }, - ], - }, - ], - }, - // END UI ACTIONS - ]); -} From a8b122989586a503e15375f28e641153d3ae4d67 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:22:50 -0700 Subject: [PATCH 06/43] ref(cmdk): delete context.tsx, move CommandPaletteProvider to cmdk.tsx The old context.tsx served as a registration hub for the reducer-based action system. With that system removed, it only wrapped two providers. Move CommandPaletteProvider directly into cmdk.tsx alongside the other palette primitives and delete the now-empty file. Also remove unused WithKey type variants from types.tsx. Co-Authored-By: Claude Sonnet 4 --- static/app/bootstrap/processInitQueue.tsx | 2 +- .../commandPalette/__stories__/components.tsx | 2 +- .../app/components/commandPalette/context.tsx | 180 ------------------ .../app/components/commandPalette/types.tsx | 66 +------ .../app/components/commandPalette/ui/cmdk.tsx | 19 +- .../commandPalette/ui/commandPalette.spec.tsx | 2 +- static/app/main.tsx | 2 +- tests/js/sentry-test/reactTestingLibrary.tsx | 2 +- 8 files changed, 20 insertions(+), 255 deletions(-) delete mode 100644 static/app/components/commandPalette/context.tsx diff --git a/static/app/bootstrap/processInitQueue.tsx b/static/app/bootstrap/processInitQueue.tsx index 13db2dd226fe4b..8a6b089b9ee9f0 100644 --- a/static/app/bootstrap/processInitQueue.tsx +++ b/static/app/bootstrap/processInitQueue.tsx @@ -4,7 +4,7 @@ import {createBrowserRouter, RouterProvider} from 'react-router-dom'; import throttle from 'lodash/throttle'; import {exportedGlobals} from 'sentry/bootstrap/exportGlobals'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; +import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {ScrapsProviders} from 'sentry/scrapsProviders'; diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 7dca37197ff446..7855eb291ad688 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -1,7 +1,7 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; +import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; diff --git a/static/app/components/commandPalette/context.tsx b/static/app/components/commandPalette/context.tsx deleted file mode 100644 index 65bb2470d5ba77..00000000000000 --- a/static/app/components/commandPalette/context.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useReducer, -} from 'react'; -import {uuid4} from '@sentry/core'; - -import {slugify} from 'sentry/utils/slugify'; -import {unreachable} from 'sentry/utils/unreachable'; - -import {CMDKProvider} from './ui/cmdk'; -import {CommandPaletteStateProvider} from './ui/commandPaletteStateContext'; -import type {CommandPaletteAction, CommandPaletteActionWithKey} from './types'; - -function addKeysToActions( - actions: CommandPaletteAction[] -): CommandPaletteActionWithKey[] { - return actions.map(action => { - const kind = 'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback'; - const actionKey = `${kind}:${slugify(action.display.label)}:${uuid4()}`; - - if ('actions' in action) { - return { - ...action, - actions: addKeysToChildActions(actionKey, action.actions), - key: actionKey, - }; - } - - return { - ...action, - key: actionKey, - }; - }); -} - -function addKeysToChildActions( - parentKey: string, - actions: CommandPaletteAction[] -): CommandPaletteActionWithKey[] { - return actions.map(action => { - const actionKey = `${parentKey}::${'actions' in action ? 'group' : 'to' in action ? 'navigate' : 'callback'}:${slugify(action.display.label)}`; - - if ('actions' in action) { - return { - ...action, - actions: addKeysToChildActions(actionKey, action.actions), - key: actionKey, - }; - } - - return { - ...action, - key: actionKey, - }; - }); -} - -type CommandPaletteProviderProps = {children: React.ReactNode}; -type CommandPaletteActions = CommandPaletteActionWithKey[]; - -type Unregister = () => void; -type CommandPaletteRegistration = { - dispatch: React.Dispatch; - registerActions: (actions: CommandPaletteAction[]) => Unregister; -}; - -type CommandPaletteActionReducerAction = - | { - actions: CommandPaletteActionWithKey[]; - type: 'register'; - } - | { - keys: string[]; - type: 'unregister'; - }; - -const CommandPaletteRegistrationContext = - createContext(null); -const CommandPaletteActionsContext = createContext(null); - -function useCommandPaletteRegistration(): CommandPaletteRegistration { - const ctx = useContext(CommandPaletteRegistrationContext); - if (ctx === null) { - throw new Error( - 'useCommandPaletteRegistration must be wrapped in CommandPaletteProvider' - ); - } - return ctx; -} - -export function useCommandPaletteActions(): CommandPaletteActionWithKey[] { - const ctx = useContext(CommandPaletteActionsContext); - if (ctx === null) { - throw new Error('useCommandPaletteActions must be wrapped in CommandPaletteProvider'); - } - return ctx; -} - -function actionsReducer( - state: CommandPaletteActionWithKey[], - reducerAction: CommandPaletteActionReducerAction -): CommandPaletteActionWithKey[] { - const type = reducerAction.type; - switch (type) { - case 'register': { - const result = [...state]; - - for (const newAction of reducerAction.actions) { - const existingIndex = result.findIndex(action => action.key === newAction.key); - - if (existingIndex >= 0) { - result[existingIndex] = newAction; - } else { - result.push(newAction); - } - } - - return result; - } - case 'unregister': - // @TODO(Jonas): this needs to support deep unregistering of actions - return state.filter(action => !reducerAction.keys.includes(action.key)); - default: - unreachable(type); - return state; - } -} - -export function CommandPaletteProvider({children}: CommandPaletteProviderProps) { - const [actions, dispatch] = useReducer(actionsReducer, []); - - const registerActions = useCallback( - (newActions: CommandPaletteAction[]) => { - const actionsWithKeys = addKeysToActions(newActions); - - dispatch({type: 'register', actions: actionsWithKeys}); - return () => { - dispatch({type: 'unregister', keys: actionsWithKeys.map(a => a.key)}); - }; - }, - [dispatch] - ); - - const registerContext = useMemo( - () => ({registerActions, dispatch}), - [registerActions, dispatch] - ); - return ( - - - - {children} - - - - ); -} - -/** - * Use this hook inside your page or feature component to register contextual actions with the global command palette. - * Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while - * your component is rendered. This is ideal for page‑specific shortcuts. - * - * There are a few different types of actions you can register: - * - * - **Navigation actions**: Provide a `to` destination to navigate to when selected. - * - **Callback actions**: Provide an `onAction` handler to execute when selected. - * - **Nested actions**: Provide an `actions: CommandPaletteAction[]` array on a parent item to show a second level. Selecting the parent reveals its children. - * - * See the CommandPaletteAction type for more details on configuration. - */ -export function useCommandPaletteActionsRegister(actions: CommandPaletteAction[]) { - const {registerActions} = useCommandPaletteRegistration(); - - useEffect(() => registerActions(actions), [actions, registerActions]); -} diff --git a/static/app/components/commandPalette/types.tsx b/static/app/components/commandPalette/types.tsx index 0005d50ce7b82d..9a5c0856194b50 100644 --- a/static/app/components/commandPalette/types.tsx +++ b/static/app/components/commandPalette/types.tsx @@ -18,17 +18,10 @@ interface Action { /** * Actions that can be returned from an async resource query. - * Async results cannot themselves carry a `resource` — chained async lookups - * are not supported. Use CommandPaletteAction for registering top-level actions. */ -interface CommandPaletteAsyncResultGroup extends Action { - actions: CommandPaletteAsyncResult[]; -} - export type CommandPaletteAsyncResult = | CommandPaletteActionLink - | CommandPaletteActionCallback - | CommandPaletteAsyncResultGroup; + | CommandPaletteActionCallback; export type CMDKQueryOptions = UseQueryOptions< any, @@ -43,70 +36,15 @@ export interface CommandPaletteActionLink extends Action { } interface CommandPaletteActionCallback extends Action { - /** - * Execute a callback when the action is selected. - * Use the `to` prop if you want to navigate to a route. - */ onAction: () => void; } -interface CommandPaletteAsyncAction extends Action { - /** - * Execute a callback when the action is selected. - * Use the `to` prop if you want to navigate to a route. - */ - resource: (query: string) => CMDKQueryOptions; -} - -interface CommandPaletteAsyncActionGroup extends Action { - actions: CommandPaletteAction[]; - resource: (query: string) => CMDKQueryOptions; -} - export type CommandPaletteAction = | CommandPaletteActionLink | CommandPaletteActionCallback - | CommandPaletteActionGroup - | CommandPaletteAsyncAction - | CommandPaletteAsyncActionGroup; + | CommandPaletteActionGroup; interface CommandPaletteActionGroup extends Action { /** Nested actions to show when this action is selected */ actions: CommandPaletteAction[]; } - -// Internally, a key is added to the actions in order to track them for registration and selection. -type CommandPaletteActionLinkWithKey = CommandPaletteActionLink & {key: string}; -type CommandPaletteActionCallbackWithKey = CommandPaletteActionCallback & { - key: string; -}; -type CommandPaletteAsyncActionWithKey = CommandPaletteAsyncAction & { - key: string; -}; -type CommandPaletteAsyncActionGroupWithKey = Omit< - CommandPaletteAsyncActionGroup, - 'actions' -> & { - actions: CommandPaletteActionWithKey[]; - key: string; -}; - -export type CommandPaletteActionWithKey = - // Sync actions (to, callback, group) - | CommandPaletteActionLinkWithKey - | CommandPaletteActionCallbackWithKey - | CommandPaletteActionGroupWithKey - // Async actions - | CommandPaletteAsyncActionWithKey - | CommandPaletteAsyncActionGroupWithKey; - -interface CommandPaletteActionGroupWithKey extends CommandPaletteActionGroup { - actions: Array< - | CommandPaletteActionLinkWithKey - | CommandPaletteActionCallbackWithKey - | CommandPaletteActionGroupWithKey - | CommandPaletteAsyncActionWithKey - | CommandPaletteAsyncActionGroupWithKey - >; - key: string; -} diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 5f9acc9ab10fd4..35f3972441147e 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -7,7 +7,10 @@ import type { } from 'sentry/components/commandPalette/types'; import {makeCollection} from './collection'; -import {useCommandPaletteState} from './commandPaletteStateContext'; +import { + CommandPaletteStateProvider, + useCommandPaletteState, +} from './commandPaletteStateContext'; interface DisplayProps { label: string; @@ -29,12 +32,17 @@ export type CMDKActionData = }; export const CMDKCollection = makeCollection(); + /** - * Root provider for the CMDK collection. Must be mounted inside - * CommandPaletteStateProvider because it reads the current query from it. + * Root provider for the command palette. Wrap the component tree that + * contains CMDKGroup/CMDKAction registrations and the CommandPalette UI. */ -export function CMDKProvider({children}: {children: React.ReactNode}) { - return {children}; +export function CommandPaletteProvider({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); } interface CMDKGroupProps { @@ -55,7 +63,6 @@ type CMDKActionProps = */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { const key = CMDKCollection.useRegisterNode({display, keywords, resource}); - const {query} = useCommandPaletteState(); const {data} = useQuery({ diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 691476a3c295d3..ff955f3a7c49c6 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -24,8 +24,8 @@ jest.mock('@tanstack/react-virtual', () => ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; diff --git a/static/app/main.tsx b/static/app/main.tsx index 239cff7e236a98..22ffb57b05a4b5 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -8,7 +8,7 @@ import {ReactQueryDevtoolsPanel} from '@tanstack/react-query-devtools'; import {NuqsAdapter} from 'nuqs/adapters/react-router/v6'; import {AppQueryClientProvider} from 'sentry/appQueryClient'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; +import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {FrontendVersionProvider} from 'sentry/components/frontendVersionContext'; import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; diff --git a/tests/js/sentry-test/reactTestingLibrary.tsx b/tests/js/sentry-test/reactTestingLibrary.tsx index f38ea213b555ac..17ef38be401d6a 100644 --- a/tests/js/sentry-test/reactTestingLibrary.tsx +++ b/tests/js/sentry-test/reactTestingLibrary.tsx @@ -22,7 +22,7 @@ import * as qs from 'query-string'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {ThemeFixture} from 'sentry-fixture/theme'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; +import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {GlobalDrawer} from 'sentry/components/globalDrawer'; import {GlobalModal} from 'sentry/components/globalModal'; import type {Organization} from 'sentry/types/organization'; From dbca9284019966437f5af20b9af52d391d3233ca Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:25:18 -0700 Subject: [PATCH 07/43] ref(cmdk): move GlobalCommandPaletteActions into modal Move GlobalCommandPaletteActions from globalActions.tsx into the ui/ directory alongside the other palette files, and render it inside the modal rather than in the navigation layout. The global actions now mount and unmount with the palette itself instead of running in the navigation tree at all times. CommandPalette stays a generic rendering component with no app-specific dependencies, keeping it fully testable in isolation. Co-Authored-By: Claude Sonnet 4 --- .../{globalActions.tsx => ui/commandPaletteGlobalActions.tsx} | 2 +- static/app/components/commandPalette/ui/modal.tsx | 2 ++ static/app/views/navigation/index.tsx | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) rename static/app/components/commandPalette/{globalActions.tsx => ui/commandPaletteGlobalActions.tsx} (99%) diff --git a/static/app/components/commandPalette/globalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx similarity index 99% rename from static/app/components/commandPalette/globalActions.tsx rename to static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 1a4b49e474841b..f861fcceb87ede 100644 --- a/static/app/components/commandPalette/globalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -49,7 +49,7 @@ import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/i import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; -import {CMDKAction, CMDKGroup} from './ui/cmdk'; +import {CMDKAction, CMDKGroup} from './cmdk'; const DSN_ICONS: React.ReactElement[] = [ , diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 2c8cd41c7c2a86..09d5e02c4820e1 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -6,6 +6,7 @@ import {closeModal} 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 {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'; @@ -27,6 +28,7 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { return ( + ); diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 7b9417240fb2b5..5433ac62400abe 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -5,7 +5,6 @@ import {useHotkeys} from '@sentry/scraps/hotkey'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/globalActions'; import {CommandPaletteHotkeys} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {t} from 'sentry/locale'; @@ -49,7 +48,6 @@ function UserAndOrganizationNavigation() { return ( - {layout === 'mobile' ? ( From c1b7b0ec4de996bb02639cccd6c8e917d6cfb771 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:27:21 -0700 Subject: [PATCH 08/43] ref(cmdk): CommandPalette accepts children for action registration Add a children prop so callers can pass CMDKGroup/CMDKAction trees directly to CommandPalette. The modal uses this to inject GlobalCommandPaletteActions, keeping the component itself free of app-specific dependencies. Co-Authored-By: Claude Sonnet 4 --- static/app/components/commandPalette/ui/commandPalette.tsx | 2 ++ static/app/components/commandPalette/ui/modal.tsx | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 1a5340416399de..8608fef811ca4f 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -65,6 +65,7 @@ type CMDKFlatItem = CollectionTreeNode & { interface CommandPaletteProps { onAction: (action: CollectionTreeNode) => void; + children?: React.ReactNode; } export function CommandPalette(props: CommandPaletteProps) { @@ -226,6 +227,7 @@ export function CommandPalette(props: CommandPaletteProps) { return ( + {props.children} {p => { diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 09d5e02c4820e1..581e8c93255a13 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -28,8 +28,9 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { return ( - - + + + ); } From a5330ebcdffe7311d5c49de18b244ca65bcea4ea Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:29:37 -0700 Subject: [PATCH 09/43] fix(cmdk): pass LocationDescriptor directly instead of stringifying CMDKAction now accepts LocationDescriptor for the to prop, so String() is no longer needed and was triggering a no-base-to-string lint error. Co-Authored-By: Claude Sonnet 4 --- static/app/components/commandPalette/ui/commandPalette.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index ff955f3a7c49c6..af4703e48227e5 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -52,7 +52,7 @@ function ActionsToJSX({actions}: {actions: CommandPaletteAction[]}) { ); From 12c8da6e77fbeaec822a5d59a3add26323219482 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:32:38 -0700 Subject: [PATCH 10/43] fix(cmdk): guard nav sidebar toggle against missing SecondaryNavigationContext The modal renders outside SecondaryNavigationContextProvider, so useSecondaryNavigation() threw on open. Read the context directly and skip the sidebar toggle action when the context isn't available. Co-Authored-By: Claude Sonnet 4 --- .../ui/commandPaletteGlobalActions.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index f861fcceb87ede..c76939a615d8e0 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -1,4 +1,4 @@ -import {Fragment, useState} from 'react'; +import {Fragment, useContext, useState} from 'react'; import {SentryGlobalSearch} from '@sentry-internal/global-search'; import DOMPurify from 'dompurify'; @@ -46,7 +46,7 @@ import {MCP_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mcp/settings'; import {MOBILE_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mobile/settings'; import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; -import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; +import {SecondaryNavigationContext} from 'sentry/views/navigation/secondaryNavigationContext'; import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; import {CMDKAction, CMDKGroup} from './cmdk'; @@ -139,8 +139,7 @@ export function GlobalCommandPaletteActions() { const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); const {starredViews} = useStarredIssueViews(); const {data: starredDashboards = []} = useGetStarredDashboards(); - const {view, setView} = useSecondaryNavigation(); - const isNavCollapsed = view !== 'expanded'; + const secondaryNav = useContext(SecondaryNavigationContext); const [search] = useState(() => helpSearch); const slug = organization.slug; @@ -374,15 +373,26 @@ export function GlobalCommandPaletteActions() { {/* ── Interface ── */} - , - }} - onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} - /> + {secondaryNav && ( + + ), + }} + onAction={() => + secondaryNav.setView( + secondaryNav.view === 'expanded' ? 'collapsed' : 'expanded' + ) + } + /> + )} }}> Date: Mon, 6 Apr 2026 09:35:31 -0700 Subject: [PATCH 11/43] fix(cmdk): restore GlobalCommandPaletteActions to navigation scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving it into the modal changed its React scope — the modal renders in a portal outside SecondaryNavigationContextProvider, causing a crash. Since CommandPaletteProvider is at the app root, CMDKCollection is shared across the whole tree. GlobalCommandPaletteActions can register from the navigation (where it has proper context) and the modal reads the same store. The children prop on CommandPalette is still useful for page-scoped actions. Co-Authored-By: Claude Sonnet 4 --- .../ui/commandPaletteGlobalActions.tsx | 36 +++++++------------ .../components/commandPalette/ui/modal.tsx | 5 +-- static/app/views/navigation/index.tsx | 2 ++ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index c76939a615d8e0..b2366c15a3ce9e 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -1,4 +1,4 @@ -import {Fragment, useContext, useState} from 'react'; +import {Fragment, useState} from 'react'; import {SentryGlobalSearch} from '@sentry-internal/global-search'; import DOMPurify from 'dompurify'; @@ -46,7 +46,7 @@ import {MCP_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mcp/settings'; import {MOBILE_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mobile/settings'; import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; -import {SecondaryNavigationContext} from 'sentry/views/navigation/secondaryNavigationContext'; +import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; import {CMDKAction, CMDKGroup} from './cmdk'; @@ -139,7 +139,7 @@ export function GlobalCommandPaletteActions() { const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); const {starredViews} = useStarredIssueViews(); const {data: starredDashboards = []} = useGetStarredDashboards(); - const secondaryNav = useContext(SecondaryNavigationContext); + const {view, setView} = useSecondaryNavigation(); const [search] = useState(() => helpSearch); const slug = organization.slug; @@ -373,26 +373,16 @@ export function GlobalCommandPaletteActions() { {/* ── Interface ── */} - {secondaryNav && ( - - ), - }} - onAction={() => - secondaryNav.setView( - secondaryNav.view === 'expanded' ? 'collapsed' : 'expanded' - ) - } - /> - )} + , + }} + onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} + /> }}> - - - + ); } diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 5433ac62400abe..90558598c78098 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -5,6 +5,7 @@ import {useHotkeys} from '@sentry/scraps/hotkey'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; +import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; import {CommandPaletteHotkeys} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {t} from 'sentry/locale'; @@ -48,6 +49,7 @@ function UserAndOrganizationNavigation() { return ( + {layout === 'mobile' ? ( From a2107d7d5aece021917e73ad60d2cc920033a98e Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 09:53:42 -0700 Subject: [PATCH 12/43] ref(cmdk) add slot context --- .../commandPalette/ui/commandPalette.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 8608fef811ca4f..a14156d1942430 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -17,6 +17,7 @@ import {InputGroup} from '@sentry/scraps/input'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {InnerWrap} from '@sentry/scraps/menuListItem'; import type {MenuListItemProps} from '@sentry/scraps/menuListItem'; +import {slot} from '@sentry/scraps/slot'; import {Text} from '@sentry/scraps/text'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; @@ -40,6 +41,8 @@ const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); const MotionContainer = motion.create(Container); +export const CommandPaletteSlot = slot(['global', 'page', 'task']); + function makeLeadingItemAnimation(theme: Theme) { return { initial: {scale: 0.95, opacity: 0}, @@ -338,6 +341,22 @@ export function CommandPalette(props: CommandPaletteProps) { }} + + + + {p =>
} + + + {p =>
} + + + {p => ( +
+ {props.children} +
+ )} +
+ {treeState.collection.size === 0 ? ( ) : ( From 15832401ebb878209e9886c7f1468e570db55596 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 10:04:00 -0700 Subject: [PATCH 13/43] ref(cmdk) add issues list actions --- .../commandPalette/ui/commandPalette.tsx | 28 ++++++------ static/app/main.tsx | 9 ++-- static/app/views/issueList/actions/index.tsx | 44 ++++++++++++++++++- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index a14156d1942430..f6394b9b6b5065 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -342,21 +342,19 @@ export function CommandPalette(props: CommandPaletteProps) { - - - {p =>
} - - - {p =>
} - - - {p => ( -
- {props.children} -
- )} -
- + + {p =>
} + + + {p =>
} + + + {p => ( +
+ {props.children} +
+ )} +
{treeState.collection.size === 0 ? ( ) : ( diff --git a/static/app/main.tsx b/static/app/main.tsx index 22ffb57b05a4b5..866f2163bd1032 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -9,6 +9,7 @@ import {NuqsAdapter} from 'nuqs/adapters/react-router/v6'; import {AppQueryClientProvider} from 'sentry/appQueryClient'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPalette'; import {FrontendVersionProvider} from 'sentry/components/frontendVersionContext'; import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; @@ -40,9 +41,11 @@ export function Main() { - - - + + + + + {USE_TANSTACK_DEVTOOL && ( diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 3affc33c3d4ca0..cb647dc3de8e99 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -13,13 +13,15 @@ import { addLoadingMessage, clearIndicators, } from 'sentry/actionCreators/indicator'; +import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPalette'; import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {Sticky} from 'sentry/components/sticky'; 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, type Group} from 'sentry/types/group'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; @@ -102,6 +104,46 @@ function ActionsBarPriority({ return ( + + + + {anySelected && ( + + handleUpdate({status: GroupStatus.RESOLVED, statusDetails: {}}) + } + /> + )} + {anySelected && ( + + handleUpdate({ + status: GroupStatus.IGNORED, + statusDetails: {}, + substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, + }) + } + /> + )} + {anySelected && multiSelected && ( + + )} + {anySelected && ( + + )} + + {!narrowViewport && ( Date: Mon, 6 Apr 2026 10:26:58 -0700 Subject: [PATCH 14/43] test(cmdk): Add failing test for slot rendering priority and fix missing providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test asserting that page slot actions appear before global actions in the command palette list. The test currently fails — it documents the known bug where collection insertion order (driven by React mount order) determines list position instead of slot priority. Also fixes two pre-existing issues uncovered while writing the test: - GlobalActionsComponent in the spec was missing CommandPaletteSlot.Provider, causing all existing tests to throw 'SlotContext not found' at render time. - GlobalCommandPaletteActions was registering actions directly into the CMDKCollection without going through a slot consumer. It now wraps its output in , making all action registration slot-aware and setting up the path toward DOM-order-based priority sorting. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/ui/commandPalette.spec.tsx | 42 +- .../ui/commandPaletteGlobalActions.tsx | 461 +++++++++--------- 2 files changed, 275 insertions(+), 228 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index af4703e48227e5..33baccdb0d5bb0 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -29,7 +29,10 @@ import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKAction, CMDKGroup} 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 { + CommandPalette, + CommandPaletteSlot, +} from 'sentry/components/commandPalette/ui/commandPalette'; import {useNavigate} from 'sentry/utils/useNavigate'; /** @@ -96,9 +99,11 @@ function GlobalActionsComponent({ return ( - - - {children} + + + + {children} + ); } @@ -415,4 +420,33 @@ describe('CommandPalette', () => { await waitFor(() => expect(input).toHaveValue('parent')); }); }); + + describe('slot rendering', () => { + it('page slot actions are rendered before global actions', async () => { + // This test mirrors the real app structure: + // - Global actions are registered directly in CMDKCollection (e.g. from the nav sidebar) + // - Page-specific actions are registered via + // + // Expected: page slot actions appear first in the list, global actions second. + // The "page" outlet is rendered above the "global" outlet inside CommandPalette, + // so page slot actions should always take priority in the list order. + render( + + + {/* Global action registered directly — simulates e.g. GlobalCommandPaletteActions */} + + {/* Page-specific action portaled via the page slot */} + + + + + + + ); + + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveAccessibleName('Page Action'); + expect(options[1]).toHaveAccessibleName('Global Action'); + }); + }); }); diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index b2366c15a3ce9e..770960451a31a5 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -50,6 +50,7 @@ import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigatio import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; import {CMDKAction, CMDKGroup} from './cmdk'; +import {CommandPaletteSlot} from './commandPalette'; const DSN_ICONS: React.ReactElement[] = [ , @@ -146,270 +147,282 @@ export function GlobalCommandPaletteActions() { const prefix = `/organizations/${slug}`; return ( - - {/* ── Navigation ── */} - - }}> - - {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( - - ))} - - - {starredViews.map(starredView => ( - }} - to={`${prefix}/issues/views/${starredView.id}/`} - /> - ))} - - - }}> - - {organization.features.includes('ourlogs-enabled') && ( - - )} - - {organization.features.includes('profiling') && ( + + + {/* ── Navigation ── */} + + }}> + + {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( + + ))} - )} - {organization.features.includes('session-replay-ui') && ( - )} - - - - - }}> - - }}> - {starredDashboards.map(dashboard => ( + {starredViews.map(starredView => ( }} - to={`${prefix}/dashboard/${dashboard.id}/`} + key={starredView.id} + display={{label: starredView.label, icon: }} + to={`${prefix}/issues/views/${starredView.id}/`} /> ))} - - {organization.features.includes('performance-view') && ( - }}> - + }}> + + {organization.features.includes('ourlogs-enabled') && ( + + )} + {organization.features.includes('profiling') && ( + + )} + {organization.features.includes('session-replay-ui') && ( + + )} + + + }}> - - {organization.features.includes('uptime') && ( + }}> + {starredDashboards.map(dashboard => ( + }} + to={`${prefix}/dashboard/${dashboard.id}/`} + /> + ))} + + + + {organization.features.includes('performance-view') && ( + }}> + + + + + + + {organization.features.includes('uptime') && ( + + )} + + )} + + }}> + {getUserOrgNavigationConfiguration().flatMap(section => + section.items.map(item => ( + + )) )} - - )} - }}> - {getUserOrgNavigationConfiguration().flatMap(section => - section.items.map(item => ( - - )) - )} + }}> + {projects.map(project => ( + , + }} + to={`/settings/${slug}/projects/${project.slug}/`} + /> + ))} + - }}> - {projects.map(project => ( - , - }} - to={`/settings/${slug}/projects/${project.slug}/`} - /> - ))} + {/* ── Add / Create ── */} + + }} + keywords={[t('add dashboard')]} + to={`${prefix}/dashboards/new/`} + /> + }} + keywords={[t('add alert')]} + to={`${prefix}/issues/alerts/wizard/`} + /> + }} + keywords={[t('add project')]} + to={`${prefix}/projects/new/`} + /> + }} + keywords={[t('team invite')]} + onAction={openInviteMembersModal} + /> - - - {/* ── Add / Create ── */} - - }} - keywords={[t('add dashboard')]} - to={`${prefix}/dashboards/new/`} - /> - }} - keywords={[t('add alert')]} - to={`${prefix}/issues/alerts/wizard/`} - /> - }} - keywords={[t('add project')]} - to={`${prefix}/projects/new/`} - /> - }} - keywords={[t('team invite')]} - onAction={openInviteMembersModal} - /> - - {/* ── DSN Lookup ── */} - - }} - keywords={[t('client keys'), t('dsn keys')]} - > - {projects.map(project => ( - + }} + keywords={[t('client keys'), t('dsn keys')]} + > + {projects.map(project => ( + , + }} + keywords={[`dsn ${project.name}`, `dsn ${project.slug}`]} + to={`/settings/${slug}/projects/${project.slug}/keys/`} + /> + ))} + + {hasDsnLookup && ( + , + label: t('Reverse DSN lookup'), + details: t( + 'Paste a DSN into the search bar to find the project it belongs to.' + ), + icon: , }} - keywords={[`dsn ${project.name}`, `dsn ${project.slug}`]} - to={`/settings/${slug}/projects/${project.slug}/keys/`} - /> - ))} + resource={dsnLookupResource(slug)} + > + {(data: CommandPaletteAsyncResult[]) => + data.map((item, i) => renderAsyncResult(item, i)) + } + + )} - {hasDsnLookup && ( + + {/* ── Help ── */} + + }} + onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + /> + }} + onAction={() => + window.open('https://discord.gg/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') + } + /> , - }} - resource={dsnLookupResource(slug)} + display={{label: t('Search Results')}} + resource={helpSearchResource(search)} > {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) } - )} - - - {/* ── Help ── */} - - }} - onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} - /> - }} - onAction={() => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer') - } - /> - }} - onAction={() => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') - } - /> - }} - onAction={() => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') - } - /> - - {(data: CommandPaletteAsyncResult[]) => - data.map((item, i) => renderAsyncResult(item, i)) - } - - {/* ── Interface ── */} - - , - }} - onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} - /> - }}> - { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'system'}); - addSuccessMessage(t('Theme preference saved: System')); - }} - /> + {/* ── Interface ── */} + { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'light'}); - addSuccessMessage(t('Theme preference saved: Light')); - }} - /> - { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'dark'}); - addSuccessMessage(t('Theme preference saved: Dark')); + display={{ + label: + view === 'expanded' + ? t('Collapse Navigation Sidebar') + : t('Expand Navigation Sidebar'), + icon: , }} + onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} /> + }}> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'system'}); + addSuccessMessage(t('Theme preference saved: System')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'light'}); + addSuccessMessage(t('Theme preference saved: Light')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'dark'}); + addSuccessMessage(t('Theme preference saved: Dark')); + }} + /> + - - + + ); } From d4e45f25e2339084ff72c632c0c7bc4c3cfecb91 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 10:45:26 -0700 Subject: [PATCH 15/43] docs(cmdk): Add implementation plan for slot-priority pre-sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the approach for fixing slot ordering in the command palette: store a ref to the outlet DOM element on each registered action node, then pre-sort the collection output via compareDocumentPosition before passing to flattenActions. Covers the ref-threading chain (shared SlotRefsContext → outlet populates → consumer wrapper injects → node stores), the new commandPaletteSlotRefs.tsx file needed to avoid circular imports, and all six files that need changing. Co-Authored-By: Claude Sonnet 4.6 --- .../components/commandPalette/CMDK_SORT.md | 147 ++++++++++++++++++ .../commandPalette/ui/collection.tsx | 61 -------- 2 files changed, 147 insertions(+), 61 deletions(-) create mode 100644 static/app/components/commandPalette/CMDK_SORT.md diff --git a/static/app/components/commandPalette/CMDK_SORT.md b/static/app/components/commandPalette/CMDK_SORT.md new file mode 100644 index 00000000000000..d81ae4b986ea78 --- /dev/null +++ b/static/app/components/commandPalette/CMDK_SORT.md @@ -0,0 +1,147 @@ +# CMDK Slot Priority Sorting + +## Problem + +The command palette collection orders nodes by React mount time (insertion order into a +`Set`). Page- and task-scoped actions are declared inside slot consumers that portal +their content to outlet DOM nodes inside `CommandPalette`. Because those consumers mount after +the globally-registered actions (which live in the navigation sidebar and mount at startup), +global actions always appear first — even though page and task actions should take priority. + +The failing test in `commandPalette.spec.tsx` (describe "slot rendering") documents this +contract and must pass when this work is complete. + +## Solution + +Pre-sort the array returned by `store.tree()` by the DOM position of each node's slot outlet +element before passing it to `flattenActions`. Since the outlets are declared in priority order +inside `CommandPalette` (`task` → `page` → `global`), their DOM positions already encode the +correct ordering. `Node.compareDocumentPosition` gives that order for free. + +No changes to `collection.tsx` or `makeCollection` are needed — the collection stays generic. +The sort is a single pre-processing step in `commandPalette.tsx`. + +## How slot refs reach each node + +Each registered action needs to store a ref to its slot's outlet DOM element. The chain: + +1. **Outlet populates a shared ref.** `CommandPalette` creates one `RefObject` + per slot name and provides them via a `SlotRefsContext`. Each outlet's ref callback + populates the corresponding entry alongside the existing `CommandPaletteSlot.Outlet` ref. + +2. **Consumer wrapper injects the ref into context.** Named wrapper components + (`CMDKTaskSlot`, `CMDKPageSlot`, `CMDKGlobalSlot`) read their slot's ref from + `SlotRefsContext` and provide it to children via a `CurrentSlotRefContext`. + +3. **`CMDKAction` / `CMDKGroup` store the ref.** Both read `CurrentSlotRefContext` and + include the ref in the data passed to `useRegisterNode`. Because the slot library preserves + React context at the consumer's declaration site (see `collection.spec.tsx` lines 313–349), + portaled children correctly see the context provided by the consumer wrapper. + +4. **`commandPalette.tsx` pre-sorts before flattening.** The pre-sort reads each node's stored + ref and calls `compareDocumentPosition`. Nodes without a ref (no slot wrapper) compare as + equal and retain their existing relative order. + +## Changes required + +### 1. New file — `commandPaletteSlotRefs.tsx` + +Create a shared context to avoid a circular import between `cmdk.tsx` and `commandPalette.tsx` +(cmdk imports commandPalette for `CommandPaletteSlot`; commandPalette imports cmdk for +`CMDKCollection`). + +```ts +export const SlotRefsContext = createContext<{ + task: React.RefObject; + page: React.RefObject; + global: React.RefObject; +} | null>(null); + +export const CurrentSlotRefContext = + createContext | null>(null); +``` + +### 2. `cmdk.tsx` + +- Import `CurrentSlotRefContext` from the new file. +- Add `slotRef?: React.RefObject` to all three `CMDKActionData` variants. +- In `CMDKAction` and `CMDKGroup`, read `CurrentSlotRefContext` and forward it as `slotRef` + in the data passed to `useRegisterNode`. + +### 3. `commandPalette.tsx` + +- Import `SlotRefsContext` and `CurrentSlotRefContext` from the new file. +- In `CommandPalette`, create the three outlet refs with `useRef` and provide them via + `SlotRefsContext.Provider` wrapping the outlets. +- Wire each outlet's ref callback to populate the corresponding entry in `SlotRefsContext` + alongside the existing `CommandPaletteSlot.Outlet` ref: + ```tsx + + {({ref: outletRef}) => ( +
{ + slotRefs.page.current = el; + outletRef(el); + }} + style={{display: 'contents'}} + /> + )} + + ``` +- Export named consumer wrapper components that inject the right ref into + `CurrentSlotRefContext`: + ```tsx + export function CMDKTaskSlot({children}: {children: React.ReactNode}) { + const slotRefs = useContext(SlotRefsContext); + return ( + + {children} + + ); + } + // Same pattern for CMDKPageSlot and CMDKGlobalSlot + ``` +- Add `presortBySlotRef` and apply it to `currentNodes` before `flattenActions`: + + ```ts + function presortBySlotRef( + nodes: Array> + ): Array> { + return [...nodes].sort((a, b) => { + const aEl = a.slotRef?.current ?? null; + const bEl = b.slotRef?.current ?? null; + if (!aEl || !bEl || aEl === bEl) return 0; + return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + } + + // In CommandPalette: + const currentNodes = presortBySlotRef(store.tree(currentRootKey)); + ``` + +### 4. `commandPaletteGlobalActions.tsx` + +Replace `` with ``. + +### 5. `static/app/views/issueList/actions/index.tsx` + +Replace `` with ``. + +### 6. `commandPalette.spec.tsx` + +- Update the slot rendering test to use `CMDKPageSlot` (or keep `CommandPaletteSlot` directly + with a manual `CurrentSlotRefContext.Provider` — either works). +- The failing test should pass once the pre-sort is in place. +- Add a three-tier test asserting task < page < global ordering when all three slots are + populated. + +## Key invariants + +- `presortBySlotRef` is a **stable** sort: nodes sharing the same outlet ref (same slot) keep + their existing relative order, preserving correct sibling ordering within a slot. +- `compareDocumentPosition` is only called when the palette is open and `CommandPalette` is + mounted — the outlet refs will always be populated at that point. +- Nodes with `slotRef = null` (no wrapper, or outlet not yet mounted) return `0` from the + comparator and are not reordered relative to each other. +- The pre-sort applies only to the root level of each `store.tree()` call. Children within a + drilled-in group are never reordered (they're already within a single slot by definition). diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index fc33a610765d19..79e3a71b0c8d6b 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -14,13 +14,6 @@ type StoredNode = { parent: string | null; }; -/** - * A node as returned by tree(). Plain data fields are spread alongside the - * structural fields (key, parent, children). - * - * A node is a "group" if it has children — there is no separate type - * discriminant. The collection is purely a structural container. - */ export type CollectionTreeNode = { children: Array>; key: string; @@ -29,71 +22,21 @@ export type CollectionTreeNode = { export interface CollectionStore { register: (node: StoredNode) => void; - /** - * Reconstruct the subtree rooted at rootKey. - * Pass null (default) to get the full tree from the top. - */ tree: (rootKey?: string | null) => Array>; unregister: (key: string) => void; } export interface CollectionInstance { - /** - * Propagates the nearest parent group's key to children. - * Use it in Group components to wrap children so they register under this node: - * - */ Context: React.Context; - - /** - * Root provider. Wrap your node tree in this component. - */ Provider: (props: {children: React.ReactNode}) => React.ReactElement; - - /** - * Registers a node on mount, unregisters on unmount. - * Returns the stable key assigned to this node. - * - * To make this node a group (i.e. allow it to have children), wrap its - * children in . - */ useRegisterNode: (data: T) => string; - - /** - * Returns the typed collection store. Call tree() to reconstruct the node - * tree at any time. - */ useStore: () => CollectionStore; } -/** - * Creates a typed collection instance. Call once at module level. - * - * There is a single type parameter T — the data shape shared by all nodes. - * A node becomes a "group" by virtue of having children registered under it - * (via Context), not by having a separate type. - * - * @example - * const CMDKCollection = makeCollection(); - * - * function CMDKGroup({ data, children }) { - * const key = CMDKCollection.useRegisterNode(data); - * return {children}; - * } - * - * function CMDKAction({ data }) { - * CMDKCollection.useRegisterNode(data); - * return null; - * } - */ export function makeCollection(): CollectionInstance { const StoreContext = createContext | null>(null); const Context = createContext(null); - // ------------------------------------------------------------------------- - // Provider - // ------------------------------------------------------------------------- - function Provider({children}: {children: React.ReactNode}) { const nodes = useRef(new Map>()); @@ -159,10 +102,6 @@ export function makeCollection(): CollectionInstance { return {children}; } - // ------------------------------------------------------------------------- - // Hooks - // ------------------------------------------------------------------------- - function useStore(): CollectionStore { const store = useContext(StoreContext); if (!store) { From 162d409044ca9d07c3467e4050865f57e7ce5120 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 11:44:25 -0700 Subject: [PATCH 16/43] feat(cmdk): Sort actions by slot priority using outlet DOM position Pre-sort the root-level CMDK collection nodes by the DOM position of their slot outlet element before flattening, so that task > page > global priority is preserved regardless of React mount order. The slot library is extended with two additions: - SlotConsumer now provides OutletNameContext so portaled children see which slot they belong to (previously only the outlet provided it) - useSlotOutletRef() hook on SlotModule returns a stable RefObject whose .current is always the current slot's outlet element CommandPaletteSlot is extracted to commandPaletteSlot.tsx to avoid a circular import between cmdk.tsx (which needs the hook) and commandPalette.tsx (which imports the collection). CMDKAction and CMDKGroup call CommandPaletteSlot.useSlotOutletRef() and store the result as ref in their node data. presortBySlotRef in commandPalette.tsx then uses compareDocumentPosition on those elements to establish the correct order. Nodes outside any slot wrapper (ref is null) sort after all slotted nodes and preserve their relative order. Co-Authored-By: Claude Sonnet 4.6 --- static/app/components/commandPalette/ui/commandPaletteSlot.tsx | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 static/app/components/commandPalette/ui/commandPaletteSlot.tsx diff --git a/static/app/components/commandPalette/ui/commandPaletteSlot.tsx b/static/app/components/commandPalette/ui/commandPaletteSlot.tsx new file mode 100644 index 00000000000000..7c620aeb037801 --- /dev/null +++ b/static/app/components/commandPalette/ui/commandPaletteSlot.tsx @@ -0,0 +1,3 @@ +import {slot} from '@sentry/scraps/slot'; + +export const CommandPaletteSlot = slot(['global', 'page', 'task']); From 406e56edde4e6261a98edd2d23226dc467a07004 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 11:44:39 -0700 Subject: [PATCH 17/43] feat(cmdk): Wire slot priority sorting into collection and palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend CMDKActionData with a ref field that stores the slot outlet element. CMDKAction and CMDKGroup call useSlotOutletRef and include it in their registered node data. commandPalette.tsx applies presortBySlotRef to collection nodes before flattening, ordering by the outlet DOM position (task > page > global). Callers use CommandPaletteSlot directly — no wrapper components needed. Co-Authored-By: Claude Sonnet 4.6 --- .../app/components/commandPalette/ui/cmdk.tsx | 22 ++++++++-- .../commandPalette/ui/collection.tsx | 2 +- .../commandPalette/ui/commandPalette.spec.tsx | 24 +++++++++++ .../commandPalette/ui/commandPalette.tsx | 28 +++++++++++-- .../ui/commandPaletteGlobalActions.tsx | 2 +- static/app/components/core/slot/slot.tsx | 42 +++++++++++++++++-- static/app/views/issueList/actions/index.tsx | 16 ++++--- 7 files changed, 117 insertions(+), 19 deletions(-) diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 35f3972441147e..e07ddaf8f61f68 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -7,6 +7,7 @@ import type { } from 'sentry/components/commandPalette/types'; import {makeCollection} from './collection'; +import {CommandPaletteSlot} from './commandPaletteSlot'; import { CommandPaletteStateProvider, useCommandPaletteState, @@ -23,11 +24,22 @@ interface DisplayProps { * having children registered under it — there is no separate group type. */ export type CMDKActionData = - | {display: DisplayProps; to: string; keywords?: string[]} - | {display: DisplayProps; onAction: () => void; keywords?: string[]} | { display: DisplayProps; + to: string; keywords?: string[]; + ref?: React.RefObject; + } + | { + display: DisplayProps; + onAction: () => void; + keywords?: string[]; + ref?: React.RefObject; + } + | { + display: DisplayProps; + keywords?: string[]; + ref?: React.RefObject; resource?: (query: string) => CMDKQueryOptions; }; @@ -62,7 +74,8 @@ type CMDKActionProps = * current query and passes results to a render-prop children function. */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { - const key = CMDKCollection.useRegisterNode({display, keywords, resource}); + const slotRef = CommandPaletteSlot.useSlotOutletRef(); + const key = CMDKCollection.useRegisterNode({display, keywords, resource, ref: slotRef}); const {query} = useCommandPaletteState(); const {data} = useQuery({ @@ -84,6 +97,7 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp * Registers a leaf action node in the collection. */ export function CMDKAction(props: CMDKActionProps) { - CMDKCollection.useRegisterNode(props); + const slotRef = CommandPaletteSlot.useSlotOutletRef(); + CMDKCollection.useRegisterNode({...props, ref: slotRef}); return null; } diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index 79e3a71b0c8d6b..0f12c0b25ba98f 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -82,7 +82,7 @@ export function makeCollection(): CollectionInstance { parent: node.parent, children: this.tree(key), ...node.dataRef.current, - } as CollectionTreeNode; + }; }); }, }), diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 33baccdb0d5bb0..49a91978afef45 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -448,5 +448,29 @@ describe('CommandPalette', () => { expect(options[0]).toHaveAccessibleName('Page Action'); expect(options[1]).toHaveAccessibleName('Global Action'); }); + + it('task < page < global ordering when all three slots are populated', async () => { + render( + + + + + + + + + + + + + + + ); + + const options = await screen.findAllByRole('option'); + expect(options[0]).toHaveAccessibleName('Task Action'); + expect(options[1]).toHaveAccessibleName('Page Action'); + expect(options[2]).toHaveAccessibleName('Global Action'); + }); }); }); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index f6394b9b6b5065..510a73d141c612 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -17,12 +17,12 @@ import {InputGroup} from '@sentry/scraps/input'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {InnerWrap} from '@sentry/scraps/menuListItem'; import type {MenuListItemProps} from '@sentry/scraps/menuListItem'; -import {slot} from '@sentry/scraps/slot'; import {Text} from '@sentry/scraps/text'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKCollection} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import { useCommandPaletteDispatch, useCommandPaletteState, @@ -41,7 +41,7 @@ const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); const MotionContainer = motion.create(Container); -export const CommandPaletteSlot = slot(['global', 'page', 'task']); +export {CommandPaletteSlot}; function makeLeadingItemAnimation(theme: Theme) { return { @@ -87,7 +87,7 @@ export function CommandPalette(props: CommandPaletteProps) { // The current navigation root: null = top-level, otherwise the key of the // group the user has drilled into. const currentRootKey = state.action?.value.key ?? null; - const currentNodes = store.tree(currentRootKey); + const currentNodes = presortBySlotRef(store.tree(currentRootKey)); const actions = useMemo(() => { if (!state.query) { @@ -382,6 +382,28 @@ export function CommandPalette(props: CommandPaletteProps) { ); } +/** + * Pre-sorts the root-level nodes by DOM position of their slot outlet element. + * Outlets are declared in priority order inside CommandPalette (task → page → global), + * so compareDocumentPosition gives the correct ordering for free. + * Nodes sharing the same outlet (same slot) retain their existing relative order. + * Nodes without a slot ref are not reordered relative to each other. + */ +function presortBySlotRef( + nodes: Array> +): Array> { + return [...nodes].sort((a, b) => { + const aEl = a.ref?.current ?? null; + const bEl = b.ref?.current ?? null; + + if (aEl === bEl) return 0; // both null, or same outlet element — preserve order + + if (!aEl) return 1; // a has no slot ref → sort after b + if (!bEl) return -1; // b has no slot ref → sort a before b + return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); +} + function scoreNode( query: string, node: CollectionTreeNode diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 770960451a31a5..17394782ec092d 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -50,7 +50,7 @@ import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigatio import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; import {CMDKAction, CMDKGroup} from './cmdk'; -import {CommandPaletteSlot} from './commandPalette'; +import {CommandPaletteSlot} from './commandPaletteSlot'; const DSN_ICONS: React.ReactElement[] = [ , diff --git a/static/app/components/core/slot/slot.tsx b/static/app/components/core/slot/slot.tsx index 7c0aab1589a295..a405d3476a0a59 100644 --- a/static/app/components/core/slot/slot.tsx +++ b/static/app/components/core/slot/slot.tsx @@ -5,6 +5,7 @@ import { useLayoutEffect, useMemo, useReducer, + useRef, } from 'react'; import {createPortal} from 'react-dom'; @@ -119,10 +120,12 @@ type SlotModule = React.FunctionComponent> Fallback: React.ComponentType; Outlet: React.ComponentType>; Provider: React.ComponentType; + useSlotOutletRef: () => React.RefObject; }; function makeSlotConsumer( - context: React.Context | null> + context: React.Context | null>, + outletNameContext: React.Context ) { function SlotConsumer(props: SlotConsumerProps): React.ReactNode { const ctx = useContext(context); @@ -138,11 +141,21 @@ function makeSlotConsumer( }, [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. + const wrappedChildren = ( + + {props.children} + + ); + if (!element) { // Render in place as a fallback when no target element is registered yet - return props.children; + return wrappedChildren; } - return createPortal(props.children, element); + return createPortal(wrappedChildren, element); } SlotConsumer.displayName = 'Slot.Consumer'; @@ -230,16 +243,37 @@ function makeSlotProvider( return SlotProvider as (props: SlotProviderProps) => React.ReactNode; } +function makeUseSlotOutletRef( + context: React.Context | null>, + outletNameContext: React.Context +): () => React.RefObject { + return function useSlotOutletRef(): React.RefObject { + const ctx = useContext(context); + const name = useContext(outletNameContext); + const ref = useRef(null); + + // Synchronously keep ref.current in sync with the outlet element for the + // current slot. Safe to assign during render since it's a ref mutation. + ref.current = ctx && name ? (ctx[0][name]?.element ?? null) : null; + + return ref; + }; +} + export function slot(names: T): SlotModule { type SlotName = T[number]; const SlotContext = createContext | null>(null); const OutletNameContext = createContext(null); - const Slot = makeSlotConsumer(SlotContext) as SlotModule; + const Slot = makeSlotConsumer( + SlotContext, + OutletNameContext + ) as SlotModule; Slot.Provider = makeSlotProvider(SlotContext); Slot.Outlet = makeSlotOutlet(SlotContext, OutletNameContext); Slot.Fallback = makeSlotFallback(SlotContext, OutletNameContext); + Slot.useSlotOutletRef = makeUseSlotOutletRef(SlotContext, OutletNameContext); // Keep `names` reference to preserve the const-narrowed type T void names; diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index cb647dc3de8e99..2e9f91235b91a9 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -14,9 +14,10 @@ import { clearIndicators, } from 'sentry/actionCreators/indicator'; import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; -import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPalette'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {IssueStreamHeaderLabel} from 'sentry/components/IssueStreamHeaderLabel'; import {Sticky} from 'sentry/components/sticky'; +import {IconList} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {GroupStore} from 'sentry/stores/groupStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -105,14 +106,17 @@ function ActionsBarPriority({ return ( - + , + }} onAction={toggleSelectAllVisible} /> {anySelected && ( }} onAction={() => handleUpdate({status: GroupStatus.RESOLVED, statusDetails: {}}) } @@ -120,7 +124,7 @@ function ActionsBarPriority({ )} {anySelected && ( }} onAction={() => handleUpdate({ status: GroupStatus.IGNORED, @@ -132,7 +136,7 @@ function ActionsBarPriority({ )} {anySelected && multiSelected && ( }} onAction={handleMerge} /> )} From d97b5984d47e61997fe472a771d8ac6c381d9e9b Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 13:27:05 -0700 Subject: [PATCH 18/43] ref(cmdk) wip --- .../components/commandPalette/CMDK_SORT.md | 147 ----- .../app/components/commandPalette/ui/cmdk.tsx | 51 +- .../commandPalette/ui/collection.tsx | 28 +- .../commandPalette/ui/commandPalette.spec.tsx | 78 ++- .../commandPalette/ui/commandPalette.tsx | 4 +- .../ui/commandPaletteGlobalActions.tsx | 515 +++++++++--------- .../components/commandPalette/ui/modal.tsx | 5 +- static/app/main.tsx | 9 +- 8 files changed, 360 insertions(+), 477 deletions(-) delete mode 100644 static/app/components/commandPalette/CMDK_SORT.md diff --git a/static/app/components/commandPalette/CMDK_SORT.md b/static/app/components/commandPalette/CMDK_SORT.md deleted file mode 100644 index d81ae4b986ea78..00000000000000 --- a/static/app/components/commandPalette/CMDK_SORT.md +++ /dev/null @@ -1,147 +0,0 @@ -# CMDK Slot Priority Sorting - -## Problem - -The command palette collection orders nodes by React mount time (insertion order into a -`Set`). Page- and task-scoped actions are declared inside slot consumers that portal -their content to outlet DOM nodes inside `CommandPalette`. Because those consumers mount after -the globally-registered actions (which live in the navigation sidebar and mount at startup), -global actions always appear first — even though page and task actions should take priority. - -The failing test in `commandPalette.spec.tsx` (describe "slot rendering") documents this -contract and must pass when this work is complete. - -## Solution - -Pre-sort the array returned by `store.tree()` by the DOM position of each node's slot outlet -element before passing it to `flattenActions`. Since the outlets are declared in priority order -inside `CommandPalette` (`task` → `page` → `global`), their DOM positions already encode the -correct ordering. `Node.compareDocumentPosition` gives that order for free. - -No changes to `collection.tsx` or `makeCollection` are needed — the collection stays generic. -The sort is a single pre-processing step in `commandPalette.tsx`. - -## How slot refs reach each node - -Each registered action needs to store a ref to its slot's outlet DOM element. The chain: - -1. **Outlet populates a shared ref.** `CommandPalette` creates one `RefObject` - per slot name and provides them via a `SlotRefsContext`. Each outlet's ref callback - populates the corresponding entry alongside the existing `CommandPaletteSlot.Outlet` ref. - -2. **Consumer wrapper injects the ref into context.** Named wrapper components - (`CMDKTaskSlot`, `CMDKPageSlot`, `CMDKGlobalSlot`) read their slot's ref from - `SlotRefsContext` and provide it to children via a `CurrentSlotRefContext`. - -3. **`CMDKAction` / `CMDKGroup` store the ref.** Both read `CurrentSlotRefContext` and - include the ref in the data passed to `useRegisterNode`. Because the slot library preserves - React context at the consumer's declaration site (see `collection.spec.tsx` lines 313–349), - portaled children correctly see the context provided by the consumer wrapper. - -4. **`commandPalette.tsx` pre-sorts before flattening.** The pre-sort reads each node's stored - ref and calls `compareDocumentPosition`. Nodes without a ref (no slot wrapper) compare as - equal and retain their existing relative order. - -## Changes required - -### 1. New file — `commandPaletteSlotRefs.tsx` - -Create a shared context to avoid a circular import between `cmdk.tsx` and `commandPalette.tsx` -(cmdk imports commandPalette for `CommandPaletteSlot`; commandPalette imports cmdk for -`CMDKCollection`). - -```ts -export const SlotRefsContext = createContext<{ - task: React.RefObject; - page: React.RefObject; - global: React.RefObject; -} | null>(null); - -export const CurrentSlotRefContext = - createContext | null>(null); -``` - -### 2. `cmdk.tsx` - -- Import `CurrentSlotRefContext` from the new file. -- Add `slotRef?: React.RefObject` to all three `CMDKActionData` variants. -- In `CMDKAction` and `CMDKGroup`, read `CurrentSlotRefContext` and forward it as `slotRef` - in the data passed to `useRegisterNode`. - -### 3. `commandPalette.tsx` - -- Import `SlotRefsContext` and `CurrentSlotRefContext` from the new file. -- In `CommandPalette`, create the three outlet refs with `useRef` and provide them via - `SlotRefsContext.Provider` wrapping the outlets. -- Wire each outlet's ref callback to populate the corresponding entry in `SlotRefsContext` - alongside the existing `CommandPaletteSlot.Outlet` ref: - ```tsx - - {({ref: outletRef}) => ( -
{ - slotRefs.page.current = el; - outletRef(el); - }} - style={{display: 'contents'}} - /> - )} - - ``` -- Export named consumer wrapper components that inject the right ref into - `CurrentSlotRefContext`: - ```tsx - export function CMDKTaskSlot({children}: {children: React.ReactNode}) { - const slotRefs = useContext(SlotRefsContext); - return ( - - {children} - - ); - } - // Same pattern for CMDKPageSlot and CMDKGlobalSlot - ``` -- Add `presortBySlotRef` and apply it to `currentNodes` before `flattenActions`: - - ```ts - function presortBySlotRef( - nodes: Array> - ): Array> { - return [...nodes].sort((a, b) => { - const aEl = a.slotRef?.current ?? null; - const bEl = b.slotRef?.current ?? null; - if (!aEl || !bEl || aEl === bEl) return 0; - return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; - }); - } - - // In CommandPalette: - const currentNodes = presortBySlotRef(store.tree(currentRootKey)); - ``` - -### 4. `commandPaletteGlobalActions.tsx` - -Replace `` with ``. - -### 5. `static/app/views/issueList/actions/index.tsx` - -Replace `` with ``. - -### 6. `commandPalette.spec.tsx` - -- Update the slot rendering test to use `CMDKPageSlot` (or keep `CommandPaletteSlot` directly - with a manual `CurrentSlotRefContext.Provider` — either works). -- The failing test should pass once the pre-sort is in place. -- Add a three-tier test asserting task < page < global ordering when all three slots are - populated. - -## Key invariants - -- `presortBySlotRef` is a **stable** sort: nodes sharing the same outlet ref (same slot) keep - their existing relative order, preserving correct sibling ordering within a slot. -- `compareDocumentPosition` is only called when the palette is open and `CommandPalette` is - mounted — the outlet refs will always be populated at that point. -- Nodes with `slotRef = null` (no wrapper, or outlet not yet mounted) return `0` from the - comparator and are not reordered relative to each other. -- The pre-sort applies only to the root level of each `store.tree()` call. Children within a - drilled-in group are never reordered (they're already within a single slot by definition). diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index e07ddaf8f61f68..fd2eb797f261d4 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -5,9 +5,9 @@ import type { CMDKQueryOptions, CommandPaletteAsyncResult, } from 'sentry/components/commandPalette/types'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {makeCollection} from './collection'; -import {CommandPaletteSlot} from './commandPaletteSlot'; import { CommandPaletteStateProvider, useCommandPaletteState, @@ -19,29 +19,32 @@ interface DisplayProps { icon?: React.ReactNode; } +interface CMDKActionDataBase { + display: DisplayProps; + keywords?: string[]; + ref?: React.RefObject; +} + +interface CMDKActionDataTo extends CMDKActionDataBase { + to: string; +} + +interface CMDKActionDataOnAction extends CMDKActionDataBase { + onAction: () => void; +} + +interface CMDKActionDataResource extends CMDKActionDataBase { + resource?: (query: string) => CMDKQueryOptions; +} + /** * Single data shape for all CMDK nodes. A node becomes a group by virtue of * having children registered under it — there is no separate group type. */ export type CMDKActionData = - | { - display: DisplayProps; - to: string; - keywords?: string[]; - ref?: React.RefObject; - } - | { - display: DisplayProps; - onAction: () => void; - keywords?: string[]; - ref?: React.RefObject; - } - | { - display: DisplayProps; - keywords?: string[]; - ref?: React.RefObject; - resource?: (query: string) => CMDKQueryOptions; - }; + | CMDKActionDataTo + | CMDKActionDataOnAction + | CMDKActionDataResource; export const CMDKCollection = makeCollection(); @@ -52,7 +55,9 @@ export const CMDKCollection = makeCollection(); export function CommandPaletteProvider({children}: {children: React.ReactNode}) { return ( - {children} + + {children} + ); } @@ -74,8 +79,7 @@ type CMDKActionProps = * current query and passes results to a render-prop children function. */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { - const slotRef = CommandPaletteSlot.useSlotOutletRef(); - const key = CMDKCollection.useRegisterNode({display, keywords, resource, ref: slotRef}); + const key = CMDKCollection.useRegisterNode({display, keywords, resource}); const {query} = useCommandPaletteState(); const {data} = useQuery({ @@ -97,7 +101,6 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp * Registers a leaf action node in the collection. */ export function CMDKAction(props: CMDKActionProps) { - const slotRef = CommandPaletteSlot.useSlotOutletRef(); - CMDKCollection.useRegisterNode({...props, ref: slotRef}); + CMDKCollection.useRegisterNode(props); return null; } diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index 0f12c0b25ba98f..655319ce8af0b9 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -35,6 +35,7 @@ export interface CollectionInstance { export function makeCollection(): CollectionInstance { const StoreContext = createContext | null>(null); + const Context = createContext(null); function Provider({children}: {children: React.ReactNode}) { @@ -45,14 +46,11 @@ export function makeCollection(): CollectionInstance { // effect ordering: siblings register before their next sibling's subtree fires). const childIndex = useRef(new Map>()); - // Tracks whether any registrations happened since the last flush. - // register/unregister mutate refs and increment this counter. They do NOT call - // bump() directly — that would cause a synchronous re-render mid-registration - // and leave consumers seeing a partial tree. - const pendingVersion = useRef(0); - const flushedVersion = useRef(0); - - const [, bump] = useReducer(x => x + 1, 0); + // Increment version on every structural change (register/unregister). + // React 18 automatic batching ensures that multiple dispatches from a + // single commit's layout-effect phase are coalesced into one re-render, + // so callers always see a complete (non-partial) tree. + const [_, bump] = useReducer(x => (x + 1) % 2, 0); const store = useMemo>( () => ({ @@ -61,7 +59,7 @@ export function makeCollection(): CollectionInstance { const siblings = childIndex.current.get(node.parent) ?? new Set(); siblings.add(node.key); childIndex.current.set(node.parent, siblings); - pendingVersion.current++; + bump(); }, unregister(key) { @@ -70,7 +68,7 @@ export function makeCollection(): CollectionInstance { nodes.current.delete(key); childIndex.current.get(node.parent)?.delete(key); childIndex.current.delete(key); - pendingVersion.current++; + bump(); }, tree(rootKey = null): Array> { @@ -89,16 +87,6 @@ export function makeCollection(): CollectionInstance { [] ); - // This effect runs AFTER all descendants' useLayoutEffects (parent fires last). - // If registrations changed since the last flush, trigger one re-render so - // consumers see the complete, stable tree. - useLayoutEffect(() => { - if (pendingVersion.current !== flushedVersion.current) { - flushedVersion.current = pendingVersion.current; - bump(); - } - }); - return {children}; } diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 49a91978afef45..9e209ff722cb7f 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -29,10 +29,8 @@ import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKAction, CMDKGroup} 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, - CommandPaletteSlot, -} from 'sentry/components/commandPalette/ui/commandPalette'; +import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {useNavigate} from 'sentry/utils/useNavigate'; /** @@ -422,6 +420,78 @@ describe('CommandPalette', () => { }); describe('slot rendering', () => { + it('task slot action is displayed in the palette', async () => { + render( + + + + + + + + + ); + + expect( + await screen.findByRole('option', {name: 'Task Action'}) + ).toBeInTheDocument(); + }); + + it('task slot action triggers its callback when selected', async () => { + const onAction = jest.fn(); + render( + + + + + + ('onAction' in node ? node.onAction() : null)} + /> + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'Task Action'})); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('page slot action is displayed in the palette', async () => { + render( + + + + + + + + + ); + + expect( + await screen.findByRole('option', {name: 'Page Action'}) + ).toBeInTheDocument(); + }); + + it('page slot action triggers its callback when selected', async () => { + const onAction = jest.fn(); + render( + + + + + + ('onAction' in node ? node.onAction() : null)} + /> + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'Page Action'})); + expect(onAction).toHaveBeenCalledTimes(1); + }); + it('page slot actions are rendered before global actions', async () => { // This test mirrors the real app structure: // - Global actions are registered directly in CMDKCollection (e.g. from the nav sidebar) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 510a73d141c612..89c5e9e8b2b98f 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -41,8 +41,6 @@ const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); const MotionContainer = motion.create(Container); -export {CommandPaletteSlot}; - function makeLeadingItemAnimation(theme: Theme) { return { initial: {scale: 0.95, opacity: 0}, @@ -67,8 +65,8 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { + children: React.ReactNode; onAction: (action: CollectionTreeNode) => void; - children?: React.ReactNode; } export function CommandPalette(props: CommandPaletteProps) { diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 17394782ec092d..799a53967636e4 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -1,4 +1,3 @@ -import {Fragment, useState} from 'react'; import {SentryGlobalSearch} from '@sentry-internal/global-search'; import DOMPurify from 'dompurify'; @@ -26,7 +25,6 @@ import { IconIssues, IconLock, IconOpen, - IconPanel, IconSearch, IconSettings, IconStar, @@ -46,7 +44,6 @@ import {MCP_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mcp/settings'; import {MOBILE_LANDING_SUB_PATH} from 'sentry/views/insights/pages/mobile/settings'; import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; -import {useSecondaryNavigation} from 'sentry/views/navigation/secondaryNavigationContext'; import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; import {CMDKAction, CMDKGroup} from './cmdk'; @@ -84,39 +81,38 @@ function dsnLookupResource(organizationSlug: string) { }); } -function helpSearchResource(search: SentryGlobalSearch) { - return (query: string): CMDKQueryOptions => - queryOptions({ - queryKey: ['command-palette-help-search', query, search], - queryFn: () => - search.query( - query, - {searchAllIndexes: true}, - {analyticsTags: ['source:command-palette']} - ), - select: data => { - const results: CommandPaletteAsyncResult[] = []; - for (const index of data) { - for (const hit of index.hits.slice(0, 3)) { - results.push({ - display: { - label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), - details: DOMPurify.sanitize( - hit.context?.context1 ?? hit.context?.context2 ?? '', - {ALLOWED_TAGS: []} - ), - icon: , - }, - keywords: [hit.context?.context1, hit.context?.context2].filter( - (v): v is string => typeof v === 'string' +function helpSearchResource(query: string): CMDKQueryOptions { + return queryOptions({ + queryKey: ['command-palette-help-search', query, helpSearch], + queryFn: () => + helpSearch.query( + query, + {searchAllIndexes: true}, + {analyticsTags: ['source:command-palette']} + ), + select: data => { + const results: CommandPaletteAsyncResult[] = []; + for (const index of data) { + for (const hit of index.hits.slice(0, 3)) { + results.push({ + display: { + label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), + details: DOMPurify.sanitize( + hit.context?.context1 ?? hit.context?.context2 ?? '', + {ALLOWED_TAGS: []} ), - onAction: () => window.open(hit.url, '_blank', 'noreferrer'), - }); - } + icon: , + }, + keywords: [hit.context?.context1, hit.context?.context2].filter( + (v): v is string => typeof v === 'string' + ), + onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + }); } - return results; - }, - }); + } + return results; + }, + }); } function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { @@ -140,289 +136,264 @@ export function GlobalCommandPaletteActions() { const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); const {starredViews} = useStarredIssueViews(); const {data: starredDashboards = []} = useGetStarredDashboards(); - const {view, setView} = useSecondaryNavigation(); - const [search] = useState(() => helpSearch); - const slug = organization.slug; - const prefix = `/organizations/${slug}`; + const prefix = `/organizations/${organization.slug}`; return ( - - {/* ── Navigation ── */} - - }}> - - {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( - - ))} + {/* ── Navigation ── */} + + }}> + + {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( + ))} + + + {starredViews.map(starredView => ( }} + to={`${prefix}/issues/views/${starredView.id}/`} /> - {starredViews.map(starredView => ( + ))} + + + }}> + + {organization.features.includes('ourlogs-enabled') && ( + + )} + + {organization.features.includes('profiling') && ( + + )} + {organization.features.includes('session-replay-ui') && ( + + )} + + + + + }}> + + }}> + {starredDashboards.map(dashboard => ( }} - to={`${prefix}/issues/views/${starredView.id}/`} + key={dashboard.id} + display={{label: dashboard.title, icon: }} + to={`${prefix}/dashboard/${dashboard.id}/`} /> ))} + - }}> - - {organization.features.includes('ourlogs-enabled') && ( - - )} + {organization.features.includes('performance-view') && ( + }}> - {organization.features.includes('profiling') && ( - - )} - {organization.features.includes('session-replay-ui') && ( - - )} - - - }}> - }}> - {starredDashboards.map(dashboard => ( - }} - to={`${prefix}/dashboard/${dashboard.id}/`} - /> - ))} - - - - {organization.features.includes('performance-view') && ( - }}> - - - - - - - {organization.features.includes('uptime') && ( - - )} + + + {organization.features.includes('uptime') && ( - - )} - - }}> - {getUserOrgNavigationConfiguration().flatMap(section => - section.items.map(item => ( - - )) )} + + )} - }}> - {projects.map(project => ( - , - }} - to={`/settings/${slug}/projects/${project.slug}/`} - /> - ))} - + }}> + {getUserOrgNavigationConfiguration().flatMap(section => + section.items.map(item => ( + + )) + )} - {/* ── Add / Create ── */} - - }} - keywords={[t('add dashboard')]} - to={`${prefix}/dashboards/new/`} - /> - }} - keywords={[t('add alert')]} - to={`${prefix}/issues/alerts/wizard/`} - /> - }} - keywords={[t('add project')]} - to={`${prefix}/projects/new/`} - /> - }} - keywords={[t('team invite')]} - onAction={openInviteMembersModal} - /> + }}> + {projects.map(project => ( + , + }} + to={`/settings/${organization.slug}/projects/${project.slug}/`} + /> + ))} + - {/* ── DSN Lookup ── */} - - }} - keywords={[t('client keys'), t('dsn keys')]} - > - {projects.map(project => ( - , - }} - keywords={[`dsn ${project.name}`, `dsn ${project.slug}`]} - to={`/settings/${slug}/projects/${project.slug}/keys/`} - /> - ))} - - {hasDsnLookup && ( - + }} + keywords={[t('add dashboard')]} + to={`${prefix}/dashboards/new/`} + /> + }} + keywords={[t('add alert')]} + to={`${prefix}/issues/alerts/wizard/`} + /> + }} + keywords={[t('add project')]} + to={`${prefix}/projects/new/`} + /> + }} + keywords={[t('team invite')]} + onAction={openInviteMembersModal} + /> + + + {/* ── DSN Lookup ── */} + + }} + keywords={[t('client keys'), t('dsn keys')]} + > + {projects.map(project => ( + , + label: project.name, + icon: , }} - resource={dsnLookupResource(slug)} - > - {(data: CommandPaletteAsyncResult[]) => - data.map((item, i) => renderAsyncResult(item, i)) - } - - )} + keywords={[`dsn ${project.name}`, `dsn ${project.slug}`]} + to={`/settings/${organization.slug}/projects/${project.slug}/keys/`} + /> + ))} - - {/* ── Help ── */} - - }} - onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} - /> - }} - onAction={() => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer') - } - /> - }} - onAction={() => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') - } - /> - }} - onAction={() => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') - } - /> + {hasDsnLookup && ( , + }} + resource={dsnLookupResource(organization.slug)} > {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) } + )} + + + {/* ── Help ── */} + + }} + onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + /> + }} + onAction={() => + window.open('https://discord.gg/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') + } + /> + }} + onAction={() => + window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') + } + /> + helpSearchResource(q)} + > + {(data: CommandPaletteAsyncResult[]) => + data.map((item, i) => renderAsyncResult(item, i)) + } + - {/* ── Interface ── */} - + {/* ── Interface ── */} + + }}> , + display={{label: t('System')}} + onAction={async () => { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'system'}); + addSuccessMessage(t('Theme preference saved: System')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'light'}); + addSuccessMessage(t('Theme preference saved: Light')); + }} + /> + { + addLoadingMessage(t('Saving…')); + await mutateUserOptions({theme: 'dark'}); + addSuccessMessage(t('Theme preference saved: Dark')); }} - onAction={() => setView(view === 'expanded' ? 'collapsed' : 'expanded')} /> - }}> - { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'system'}); - addSuccessMessage(t('Theme preference saved: System')); - }} - /> - { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'light'}); - addSuccessMessage(t('Theme preference saved: Light')); - }} - /> - { - addLoadingMessage(t('Saving…')); - await mutateUserOptions({theme: 'dark'}); - addSuccessMessage(t('Theme preference saved: Dark')); - }} - /> - - + ); } diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 2c8cd41c7c2a86..581e8c93255a13 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -6,6 +6,7 @@ import {closeModal} 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 {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'; @@ -27,7 +28,9 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { return ( - + + + ); } diff --git a/static/app/main.tsx b/static/app/main.tsx index 866f2163bd1032..22ffb57b05a4b5 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -9,7 +9,6 @@ import {NuqsAdapter} from 'nuqs/adapters/react-router/v6'; import {AppQueryClientProvider} from 'sentry/appQueryClient'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; -import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPalette'; import {FrontendVersionProvider} from 'sentry/components/frontendVersionContext'; import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; @@ -41,11 +40,9 @@ export function Main() { - - - - - + + + {USE_TANSTACK_DEVTOOL && ( From 562a10695bd59cbc9db7d8d2a0638e1bac8115c4 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 14:07:17 -0700 Subject: [PATCH 19/43] ref(cmdk) cleanup --- .../commandPalette/__stories__/components.tsx | 13 ++++- .../commandPalette/ui/collection.tsx | 16 ++++++- .../commandPalette/ui/commandPalette.spec.tsx | 48 +++++++++++++++++++ .../ui/commandPaletteGlobalActions.tsx | 9 +--- static/app/views/navigation/index.tsx | 1 - 5 files changed, 76 insertions(+), 11 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 7855eb291ad688..5e51e673193fa6 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -36,7 +36,18 @@ export function CommandPaletteDemo() { onAction={() => addSuccessMessage('Child action executed')} /> - + + + addSuccessMessage('Select all')} + /> + addSuccessMessage('Deselect all')} + /> + + ); } diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index 655319ce8af0b9..46dd3545649fe7 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -55,6 +55,16 @@ export function makeCollection(): CollectionInstance { const store = useMemo>( () => ({ register(node) { + const existing = nodes.current.get(node.key); + if (existing) { + if (existing.parent === node.parent) { + // Same parent: no structural change, data is kept current via dataRef. + return; + } + // Different parent: remove from the old parent's child set before + // re-inserting under the new one, so the key never appears twice. + childIndex.current.get(existing.parent)?.delete(node.key); + } nodes.current.set(node.key, node); const siblings = childIndex.current.get(node.parent) ?? new Set(); siblings.add(node.key); @@ -101,8 +111,8 @@ export function makeCollection(): CollectionInstance { function useRegisterNode(data: T): string { const store = useStore(); const parentKey = useContext(Context); - const key = useId(); + const key = useId(); // Store data in a ref so tree() always reflects the latest value without // needing to re-register when data changes. Structural changes (parentKey) // still cause a full re-registration via the effect deps. @@ -111,7 +121,9 @@ export function makeCollection(): CollectionInstance { useLayoutEffect(() => { store.register({key, parent: parentKey, dataRef}); - return () => store.unregister(key); + return () => { + store.unregister(key); + }; }, [key, parentKey, store]); return key; diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 9e209ff722cb7f..ea80e91f093771 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -435,6 +435,7 @@ describe('CommandPalette', () => { expect( await screen.findByRole('option', {name: 'Task Action'}) ).toBeInTheDocument(); + expect(screen.getAllByRole('option', {name: 'Task Action'})).toHaveLength(1); }); it('task slot action triggers its callback when selected', async () => { @@ -471,6 +472,7 @@ describe('CommandPalette', () => { expect( await screen.findByRole('option', {name: 'Page Action'}) ).toBeInTheDocument(); + expect(screen.getAllByRole('option', {name: 'Page Action'})).toHaveLength(1); }); it('page slot action triggers its callback when selected', async () => { @@ -515,6 +517,7 @@ describe('CommandPalette', () => { ); const options = await screen.findAllByRole('option'); + expect(options).toHaveLength(2); expect(options[0]).toHaveAccessibleName('Page Action'); expect(options[1]).toHaveAccessibleName('Global Action'); }); @@ -538,9 +541,54 @@ describe('CommandPalette', () => { ); const options = await screen.findAllByRole('option'); + expect(options).toHaveLength(3); expect(options[0]).toHaveAccessibleName('Task Action'); expect(options[1]).toHaveAccessibleName('Page Action'); expect(options[2]).toHaveAccessibleName('Global Action'); }); + + it('actions passed as children to CommandPalette via global slot are not duplicated', async () => { + // This mirrors the real app setup in modal.tsx where GlobalCommandPaletteActions + // is passed as children to CommandPalette. Those actions use + // internally, which creates a circular portal: + // the consumer is rendered inside the global outlet div and then portals back to it. + // Registration must be idempotent so the slot→portal transition never yields duplicates. + function ActionsViaGlobalSlot() { + return ( + + + + + ); + } + + render( + + + + + + ); + + await screen.findByRole('option', {name: 'Action A'}); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); + expect(screen.getAllByRole('option', {name: 'Action A'})).toHaveLength(1); + expect(screen.getAllByRole('option', {name: 'Action B'})).toHaveLength(1); + }); + + it('direct CMDKAction registrations outside slots are not duplicated', async () => { + render( + + + + + ); + + await screen.findByRole('option', {name: 'Direct Action'}); + expect(screen.getAllByRole('option', {name: 'Direct Action'})).toHaveLength(1); + expect(screen.getAllByRole('option')).toHaveLength(1); + }); }); }); diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 799a53967636e4..c88d4ca3a3f034 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -141,7 +141,6 @@ export function GlobalCommandPaletteActions() { return ( - {/* ── Navigation ── */} }}> @@ -166,7 +165,7 @@ export function GlobalCommandPaletteActions() { ))} - }}> + {/* }}> {organization.features.includes('ourlogs-enabled') && ( @@ -271,7 +270,6 @@ export function GlobalCommandPaletteActions() { - {/* ── Add / Create ── */} }} @@ -295,7 +293,6 @@ export function GlobalCommandPaletteActions() { /> - {/* ── DSN Lookup ── */} }} @@ -331,7 +328,6 @@ export function GlobalCommandPaletteActions() { )} - {/* ── Help ── */} }} @@ -365,7 +361,6 @@ export function GlobalCommandPaletteActions() { - {/* ── Interface ── */} }}> - + */} ); diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 90558598c78098..59fe1ead489222 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -49,7 +49,6 @@ function UserAndOrganizationNavigation() { return ( - {layout === 'mobile' ? ( From 05468efa7663f4db12cdb2ff348cf95e4688ab87 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 15:09:12 -0700 Subject: [PATCH 20/43] ref(cmdk) implement proper actions --- .../app/components/commandPalette/ui/cmdk.tsx | 6 +- .../commandPalette/ui/collection.tsx | 60 +++++++-- .../commandPalette/ui/commandPalette.spec.tsx | 3 +- .../commandPalette/ui/commandPalette.tsx | 12 +- .../ui/commandPaletteGlobalActions.tsx | 121 +++++++++--------- 5 files changed, 119 insertions(+), 83 deletions(-) diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index fd2eb797f261d4..88b2de60ada657 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -79,7 +79,8 @@ type CMDKActionProps = * current query and passes results to a render-prop children function. */ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { - const key = CMDKCollection.useRegisterNode({display, keywords, resource}); + const ref = CommandPaletteSlot.useSlotOutletRef(); + const key = CMDKCollection.useRegisterNode({display, keywords, resource, ref}); const {query} = useCommandPaletteState(); const {data} = useQuery({ @@ -101,6 +102,7 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp * Registers a leaf action node in the collection. */ export function CMDKAction(props: CMDKActionProps) { - CMDKCollection.useRegisterNode(props); + const ref = CommandPaletteSlot.useSlotOutletRef(); + CMDKCollection.useRegisterNode({...props, ref}); return null; } diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index 46dd3545649fe7..a16b3d0fd1b9ca 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -4,8 +4,8 @@ import { useId, useLayoutEffect, useMemo, - useReducer, useRef, + useSyncExternalStore, } from 'react'; type StoredNode = { @@ -21,7 +21,9 @@ export type CollectionTreeNode = { } & T; export interface CollectionStore { + getSnapshot: () => Map>; register: (node: StoredNode) => void; + subscribe: (callback: () => void) => () => void; tree: (rootKey?: string | null) => Array>; unregister: (key: string) => void; } @@ -46,14 +48,24 @@ export function makeCollection(): CollectionInstance { // effect ordering: siblings register before their next sibling's subtree fires). const childIndex = useRef(new Map>()); - // Increment version on every structural change (register/unregister). - // React 18 automatic batching ensures that multiple dispatches from a - // single commit's layout-effect phase are coalesced into one re-render, - // so callers always see a complete (non-partial) tree. - const [_, bump] = useReducer(x => (x + 1) % 2, 0); + // Snapshot ref holds a new Map instance on every structural change so that + // useSyncExternalStore can detect updates via reference inequality. + const snapshot = useRef(nodes.current); + + // Registered listener callbacks from useSyncExternalStore subscribers. + const listeners = useRef(new Set<() => void>()); const store = useMemo>( () => ({ + subscribe(callback) { + listeners.current.add(callback); + return () => listeners.current.delete(callback); + }, + + getSnapshot() { + return snapshot.current; + }, + register(node) { const existing = nodes.current.get(node.key); if (existing) { @@ -69,7 +81,8 @@ export function makeCollection(): CollectionInstance { const siblings = childIndex.current.get(node.parent) ?? new Set(); siblings.add(node.key); childIndex.current.set(node.parent, siblings); - bump(); + snapshot.current = new Map(nodes.current); + listeners.current.forEach(l => l()); }, unregister(key) { @@ -78,7 +91,8 @@ export function makeCollection(): CollectionInstance { nodes.current.delete(key); childIndex.current.get(node.parent)?.delete(key); childIndex.current.delete(key); - bump(); + snapshot.current = new Map(nodes.current); + listeners.current.forEach(l => l()); }, tree(rootKey = null): Array> { @@ -105,11 +119,37 @@ export function makeCollection(): CollectionInstance { if (!store) { throw new Error('useStore must be called inside the matching Collection Provider'); } - return store; + // Subscribe to structural changes via useSyncExternalStore. Each registration + // or unregistration produces a new snapshot Map instance, so this causes a + // re-render whenever the node tree changes. + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + // Return a new wrapper object on every snapshot change so that consumers + // using the return value as a useMemo / useCallback dependency get correct + // cache invalidation whenever the node tree changes. + return useMemo( + () => ({ + subscribe: store.subscribe, + getSnapshot: store.getSnapshot, + register: store.register, + unregister: store.unregister, + // bind so that this.tree() works correctly in recursive calls + tree: store.tree.bind(store), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [snapshot, store] + ); } function useRegisterNode(data: T): string { - const store = useStore(); + // Read the stable store from context directly — NOT via useStore() — so + // that structural node changes (which produce a new useStore() reference) + // do not invalidate the layout-effect deps and trigger re-registration loops. + const store = useContext(StoreContext); + if (!store) { + throw new Error( + 'useRegisterNode must be called inside the matching Collection Provider' + ); + } const parentKey = useContext(Context); const key = useId(); diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index ea80e91f093771..7f5176bb06b112 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -99,8 +99,7 @@ function GlobalActionsComponent({ - - {children} + {children} ); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 89c5e9e8b2b98f..243d3adabad2b1 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -65,8 +65,8 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { - children: React.ReactNode; onAction: (action: CollectionTreeNode) => void; + children?: React.ReactNode; } export function CommandPalette(props: CommandPaletteProps) { @@ -82,10 +82,11 @@ export function CommandPalette(props: CommandPaletteProps) { preload(errorIllustration, {as: 'image'}); } - // The current navigation root: null = top-level, otherwise the key of the - // group the user has drilled into. - const currentRootKey = state.action?.value.key ?? null; - const currentNodes = presortBySlotRef(store.tree(currentRootKey)); + const currentNodes = useMemo(() => { + const currentRootKey = state.action?.value.key ?? null; + const nodes = presortBySlotRef(store.tree(currentRootKey)); + return nodes; + }, [store, state.action]); const actions = useMemo(() => { if (!state.query) { @@ -228,7 +229,6 @@ export function CommandPalette(props: CommandPaletteProps) { return ( - {props.children} {p => { diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index c88d4ca3a3f034..0512e916f31f47 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -56,65 +56,6 @@ const DSN_ICONS: React.ReactElement[] = [ const helpSearch = new SentryGlobalSearch(['docs', 'develop']); -function dsnLookupResource(organizationSlug: string) { - return (query: string): CMDKQueryOptions => - queryOptions({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/dsn-lookup/', - { - path: {organizationIdOrSlug: organizationSlug}, - query: {dsn: query}, - staleTime: 30_000, - } - ), - enabled: DSN_PATTERN.test(query), - select: data => - getDsnNavTargets(data.json).map((target, i) => ({ - to: target.to, - display: { - label: target.label, - details: target.description, - icon: DSN_ICONS[i], - }, - keywords: [query], - })), - }); -} - -function helpSearchResource(query: string): CMDKQueryOptions { - return queryOptions({ - queryKey: ['command-palette-help-search', query, helpSearch], - queryFn: () => - helpSearch.query( - query, - {searchAllIndexes: true}, - {analyticsTags: ['source:command-palette']} - ), - select: data => { - const results: CommandPaletteAsyncResult[] = []; - for (const index of data) { - for (const hit of index.hits.slice(0, 3)) { - results.push({ - display: { - label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), - details: DOMPurify.sanitize( - hit.context?.context1 ?? hit.context?.context2 ?? '', - {ALLOWED_TAGS: []} - ), - icon: , - }, - keywords: [hit.context?.context1, hit.context?.context2].filter( - (v): v is string => typeof v === 'string' - ), - onAction: () => window.open(hit.url, '_blank', 'noreferrer'), - }); - } - } - return results; - }, - }); -} - function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { if ('to' in item) { return ; @@ -165,7 +106,7 @@ export function GlobalCommandPaletteActions() { ))} - {/* }}> + }}> {organization.features.includes('ourlogs-enabled') && ( @@ -319,7 +260,29 @@ export function GlobalCommandPaletteActions() { ), icon: , }} - resource={dsnLookupResource(organization.slug)} + resource={(query: string): CMDKQueryOptions => + queryOptions({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/dsn-lookup/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {dsn: query}, + staleTime: 30_000, + } + ), + enabled: DSN_PATTERN.test(query), + select: data => + getDsnNavTargets(data.json).map((target, i) => ({ + to: target.to, + display: { + label: target.label, + details: target.description, + icon: DSN_ICONS[i], + }, + keywords: [query], + })), + }) + } > {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) @@ -353,7 +316,39 @@ export function GlobalCommandPaletteActions() { /> helpSearchResource(q)} + resource={(query: string): CMDKQueryOptions => { + return queryOptions({ + queryKey: ['command-palette-help-search', query, helpSearch], + queryFn: () => + helpSearch.query( + query, + {searchAllIndexes: true}, + {analyticsTags: ['source:command-palette']} + ), + select: data => { + const results: CommandPaletteAsyncResult[] = []; + for (const index of data) { + for (const hit of index.hits.slice(0, 3)) { + results.push({ + display: { + label: DOMPurify.sanitize(hit.title ?? '', {ALLOWED_TAGS: []}), + details: DOMPurify.sanitize( + hit.context?.context1 ?? hit.context?.context2 ?? '', + {ALLOWED_TAGS: []} + ), + icon: , + }, + keywords: [hit.context?.context1, hit.context?.context2].filter( + (v): v is string => typeof v === 'string' + ), + onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + }); + } + } + return results; + }, + }); + }} > {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) @@ -387,7 +382,7 @@ export function GlobalCommandPaletteActions() { addSuccessMessage(t('Theme preference saved: Dark')); }} /> - */} + ); From eef14718a41a179a4bf0a7922faae3fc9605b418 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 15:31:27 -0700 Subject: [PATCH 21/43] fix(cmdk): Gate DSN lookup query behind DSN_PATTERN check CMDKGroup was overwriting any enabled field returned by the resource function with a static `!!resource` check. This meant the DSN lookup query always fired regardless of whether the query matched a DSN pattern. Fix CMDKGroup to respect the resource's own enabled field, then add `enabled: DSN_PATTERN.test(query)` to the DSN lookup resource so the API call only fires when the query looks like a DSN. Also remove an unused GlobalCommandPaletteActions import in navigation/index.tsx. Co-Authored-By: Claude Sonnet 4.6 --- static/app/components/commandPalette/ui/cmdk.tsx | 7 +++++-- .../commandPalette/ui/commandPaletteGlobalActions.tsx | 8 ++++---- static/app/views/navigation/index.tsx | 1 - 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 88b2de60ada657..6c365edeb387fd 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -83,9 +83,12 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp const key = CMDKCollection.useRegisterNode({display, keywords, resource, ref}); const {query} = useCommandPaletteState(); + const resourceOptions = resource + ? resource(query) + : {queryKey: [], queryFn: () => null}; const {data} = useQuery({ - ...(resource ? resource(query) : {queryKey: [], queryFn: () => null}), - enabled: !!resource, + ...resourceOptions, + enabled: !!resource && (resourceOptions.enabled ?? true), }); const resolvedChildren = diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 0512e916f31f47..436c89e0458551 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -260,8 +260,8 @@ export function GlobalCommandPaletteActions() { ), icon: , }} - resource={(query: string): CMDKQueryOptions => - queryOptions({ + resource={(query: string): CMDKQueryOptions => { + return queryOptions({ ...apiOptions.as()( '/organizations/$organizationIdOrSlug/dsn-lookup/', { @@ -281,8 +281,8 @@ export function GlobalCommandPaletteActions() { }, keywords: [query], })), - }) - } + }); + }} > {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index 59fe1ead489222..5433ac62400abe 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -5,7 +5,6 @@ import {useHotkeys} from '@sentry/scraps/hotkey'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; -import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; import {CommandPaletteHotkeys} from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {t} from 'sentry/locale'; From 0158ce59c262d7ca3942ce2c913ddb8c09ae568b Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 6 Apr 2026 16:59:56 -0700 Subject: [PATCH 22/43] ref(cmdk) revert issues list poc --- static/app/views/issueList/actions/index.tsx | 48 +------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/static/app/views/issueList/actions/index.tsx b/static/app/views/issueList/actions/index.tsx index 2e9f91235b91a9..3affc33c3d4ca0 100644 --- a/static/app/views/issueList/actions/index.tsx +++ b/static/app/views/issueList/actions/index.tsx @@ -13,16 +13,13 @@ import { addLoadingMessage, clearIndicators, } from 'sentry/actionCreators/indicator'; -import {CMDKAction, CMDKGroup} 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 {IconList} 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 {GroupStatus, GroupSubstatus, type Group} from 'sentry/types/group'; +import type {Group} from 'sentry/types/group'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {uniq} from 'sentry/utils/array/uniq'; @@ -105,49 +102,6 @@ function ActionsBarPriority({ return ( - - - , - }} - onAction={toggleSelectAllVisible} - /> - {anySelected && ( - }} - onAction={() => - handleUpdate({status: GroupStatus.RESOLVED, statusDetails: {}}) - } - /> - )} - {anySelected && ( - }} - onAction={() => - handleUpdate({ - status: GroupStatus.IGNORED, - statusDetails: {}, - substatus: GroupSubstatus.ARCHIVED_UNTIL_ESCALATING, - }) - } - /> - )} - {anySelected && multiSelected && ( - }} - onAction={handleMerge} - /> - )} - {anySelected && ( - - )} - - {!narrowViewport && ( Date: Mon, 6 Apr 2026 17:15:33 -0700 Subject: [PATCH 23/43] docs(cmdk): Update story to JSX-powered command palette API Replace the useCommandPaletteActions hook example with the new JSX component API using CMDKAction, CMDKGroup, and CommandPaletteProvider. Also adds a section documenting the async resource pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../useCommandPaletteActions.mdx | 129 +++++++++++++----- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 342234b136075e..8dd1761aee6d01 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -1,7 +1,7 @@ --- -title: useCommandPaletteActions -description: Registers actions with the command palette -source: 'sentry/components/commandPalette/useCommandPaletteActions' +title: CommandPalette JSX API +description: Registers actions with the command palette using JSX components +source: 'sentry/components/commandPalette/ui/cmdk' --- import {Fragment} from 'react'; @@ -12,21 +12,20 @@ import {Flex, Stack} from '@sentry/scraps/layout'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {toggleCommandPalette} from 'sentry/actionCreators/modal'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; - import * as Storybook from 'sentry/stories'; -import {CommandPaletteDemo, RegisterActions} from './__stories__/components'; +import {CommandPaletteDemo} from './__stories__/components'; ## Basic Usage -Use `useCommandPaletteActions(actions)` inside your page or feature component to register contextual actions with the global command palette. Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while your component is rendered. This is ideal for page‑specific shortcuts. +Use `CMDKAction` and `CMDKGroup` JSX components inside your page or feature component to register contextual actions with the global command palette. Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while your component is rendered. This is ideal for page‑specific shortcuts. -There are a few different types of actions you can register: +Wrap your tree in `CommandPaletteProvider` and place the `CommandPalette` UI component wherever you want the dialog to render. Then declare actions anywhere inside the provider: -- **Navigation actions**: Provide a `to` destination to navigate to when selected. +- **Navigation actions**: Provide a `to` prop to navigate when selected. - **Callback actions**: Provide an `onAction` handler to execute when selected. -- **Nested actions**: Provide an `actions: CommandPaletteAction[]` array on a parent item to show a second level. Selecting the parent reveals its children. +- **Grouped actions**: Wrap `CMDKAction` children inside a `CMDKGroup` to show a second level. Selecting the parent reveals its children. +- **Async actions**: Provide a `resource` prop to a `CMDKGroup` and use the render-prop children signature to generate actions from fetched data. @@ -35,34 +34,92 @@ There are a few different types of actions you can register: ```tsx -import {useCommandPaletteActions} from 'sentry/components/commandPalette/useCommandPaletteActions'; +import {useCallback} from 'react'; + +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; +import { + CMDKAction, + CMDKGroup, + CommandPaletteProvider, +} 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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useNavigate} from 'sentry/utils/useNavigate'; function YourComponent() { - useCommandPaletteActions([ - { - display: {label: 'Go to Input story'}, - to: '/stories/core/input/', - groupingKey: 'navigate', - }, - { - display: {label: 'Execute an action'}, - groupingKey: 'help', - onAction: () => { - addSuccessMessage('Action executed'); - }, - }, - { - groupingKey: 'add', - display: {label: 'Parent action'}, - actions: [ - { - display: {label: 'Child action'}, - onAction: () => { - addSuccessMessage('Child action executed'); - }, - }, - ], + const navigate = useNavigate(); + + const handleAction = useCallback( + (action: CollectionTreeNode) => { + if ('to' in action) { + navigate(normalizeUrl(String(action.to))); + } else if ('onAction' in action) { + action.onAction(); + } }, - ]); + [navigate] + ); + + return ( + + {/* Navigation action */} + + + {/* Callback action */} + addSuccessMessage('Action executed')} + /> + + {/* Grouped actions */} + + addSuccessMessage('Child action executed')} + /> + + + {/* The command palette UI — also accepts inline actions via children */} + + + addSuccessMessage('Select all')} + /> + addSuccessMessage('Deselect all')} + /> + + + + ); } ``` + +## Async / Resource Actions + +`CMDKGroup` accepts a `resource` prop — a function that takes the current search query and returns a TanStack Query options object. The `children` prop becomes a render function that receives the fetched results: + +```tsx +import type {CommandPaletteAsyncResult} from 'sentry/components/commandPalette/types'; + + ({ + queryKey: ['/projects/', {query}], + queryFn: () => fetchProjects(query), + // transform response into CommandPaletteAsyncResult[] + select: (data): CommandPaletteAsyncResult[] => + data.map(p => ({display: {label: p.name}, to: `/projects/${p.slug}/`})), + })} +> + {(results: CommandPaletteAsyncResult[]) => + results.map(r => ( + + )) + } +; +``` From 9a0a7c6d13701b775056c324b758986688403170 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 9 Apr 2026 04:28:46 +0200 Subject: [PATCH 24/43] ref(cmdk): Merge CMDKGroup and CMDKAction into single CMDKAction component (#112563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes Merges `CMDKGroup` and `CMDKAction` into a single `CMDKAction` component. The distinction between the two was artificial — a node becomes a group simply by having children registered under it, which is already documented in the `CMDKActionData` type comment. Keeping them separate contradicted that stated design intent. The merged `CMDKAction` covers all four cases: | Usage | How | |-------|-----| | Navigation | `` | | Callback | `` | | Group with children | `` | | Async resource group | `{data => ...}` | `CMDKActionDataTo.to` is widened from `string` to `LocationDescriptor` to match `CommandPaletteAsyncResult` and the existing callsites. Co-authored-by: Claude Sonnet 4 --- .../commandPalette/__stories__/components.tsx | 10 +-- .../app/components/commandPalette/ui/cmdk.tsx | 51 ++++++++------ .../commandPalette/ui/commandPalette.spec.tsx | 6 +- .../ui/commandPaletteGlobalActions.tsx | 66 +++++++++---------- .../useCommandPaletteActions.mdx | 22 +++---- 5 files changed, 82 insertions(+), 73 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 5e51e673193fa6..ea0b08af142a3e 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -2,7 +2,7 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; -import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; +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'; @@ -30,14 +30,14 @@ export function CommandPaletteDemo() { display={{label: 'Execute an action'}} onAction={() => addSuccessMessage('Action executed')} /> - + addSuccessMessage('Child action executed')} /> - + - + addSuccessMessage('Select all')} @@ -46,7 +46,7 @@ export function CommandPaletteDemo() { display={{label: 'Deselect all'}} onAction={() => addSuccessMessage('Deselect all')} /> - + ); diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 6c365edeb387fd..5847fb97e04242 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -26,7 +26,7 @@ interface CMDKActionDataBase { } interface CMDKActionDataTo extends CMDKActionDataBase { - to: string; + to: LocationDescriptor; } interface CMDKActionDataOnAction extends CMDKActionDataBase { @@ -50,7 +50,7 @@ export const CMDKCollection = makeCollection(); /** * Root provider for the command palette. Wrap the component tree that - * contains CMDKGroup/CMDKAction registrations and the CommandPalette UI. + * contains CMDKAction registrations and the CommandPalette UI. */ export function CommandPaletteProvider({children}: {children: React.ReactNode}) { return ( @@ -62,25 +62,39 @@ export function CommandPaletteProvider({children}: {children: React.ReactNode}) ); } -interface CMDKGroupProps { +interface CMDKActionProps { display: DisplayProps; children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode); keywords?: string[]; + onAction?: () => void; resource?: (query: string) => CMDKQueryOptions; + to?: LocationDescriptor; } -type CMDKActionProps = - | {display: DisplayProps; to: LocationDescriptor; keywords?: string[]} - | {display: DisplayProps; onAction: () => void; keywords?: string[]}; - /** - * Registers a node in the collection and propagates its key to children via - * GroupContext. When a `resource` prop is provided, fetches data using the - * current query and passes results to a render-prop children function. + * Registers a node in the collection. A node becomes a group when it has + * children — they register under this node as their parent. Provide `to` for + * navigation, `onAction` for a callback, or `resource` with a render-prop + * children function to fetch and populate async results. */ -export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProps) { +export function CMDKAction({ + display, + keywords, + children, + to, + onAction, + resource, +}: CMDKActionProps) { const ref = CommandPaletteSlot.useSlotOutletRef(); - const key = CMDKCollection.useRegisterNode({display, keywords, resource, ref}); + + const nodeData: CMDKActionData = + to === undefined + ? onAction === undefined + ? {display, keywords, ref, resource} + : {display, keywords, ref, onAction} + : {display, keywords, ref, to}; + + const key = CMDKCollection.useRegisterNode(nodeData); const {query} = useCommandPaletteState(); const resourceOptions = resource @@ -91,6 +105,10 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp enabled: !!resource && (resourceOptions.enabled ?? true), }); + if (!children) { + return null; + } + const resolvedChildren = typeof children === 'function' ? (data ? children(data) : null) : children; @@ -100,12 +118,3 @@ export function CMDKGroup({display, keywords, resource, children}: CMDKGroupProp ); } - -/** - * Registers a leaf action node in the collection. - */ -export function CMDKAction(props: CMDKActionProps) { - const ref = CommandPaletteSlot.useSlotOutletRef(); - CMDKCollection.useRegisterNode({...props, ref}); - return null; -} diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 7f5176bb06b112..f31510c64c8aee 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -26,7 +26,7 @@ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; -import {CMDKAction, CMDKGroup} from 'sentry/components/commandPalette/ui/cmdk'; +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'; @@ -43,9 +43,9 @@ function ActionsToJSX({actions}: {actions: CommandPaletteAction[]}) { {actions.map((action, i) => { if ('actions' in action) { return ( - + - + ); } if ('to' in action) { diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 436c89e0458551..78a26d1d0f4456 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -46,7 +46,7 @@ import {ISSUE_TAXONOMY_CONFIG} from 'sentry/views/issueList/taxonomies'; import {useStarredIssueViews} from 'sentry/views/navigation/secondary/sections/issues/issueViews/useStarredIssueViews'; import {getUserOrgNavigationConfiguration} from 'sentry/views/settings/organization/userOrgNavigationConfiguration'; -import {CMDKAction, CMDKGroup} from './cmdk'; +import {CMDKAction} from './cmdk'; import {CommandPaletteSlot} from './commandPaletteSlot'; const DSN_ICONS: React.ReactElement[] = [ @@ -82,8 +82,8 @@ export function GlobalCommandPaletteActions() { return ( - - }}> + + }}> {Object.values(ISSUE_TAXONOMY_CONFIG).map(config => ( ))} - + - }}> + }}> {organization.features.includes('ourlogs-enabled') && ( @@ -135,14 +135,14 @@ export function GlobalCommandPaletteActions() { display={{label: t('All Queries')}} to={`${prefix}/explore/saved-queries/`} /> - + - }}> + }}> - }}> + }}> {starredDashboards.map(dashboard => ( ))} - - + + {organization.features.includes('performance-view') && ( - }}> + }}> - + )} - }}> + }}> {getUserOrgNavigationConfiguration().flatMap(section => section.items.map(item => ( )) )} - + - }}> + }}> {projects.map(project => ( ))} - - + + - + }} keywords={[t('add dashboard')]} @@ -232,10 +232,10 @@ export function GlobalCommandPaletteActions() { keywords={[t('team invite')]} onAction={openInviteMembersModal} /> - + - - + }} keywords={[t('client keys'), t('dsn keys')]} > @@ -250,9 +250,9 @@ export function GlobalCommandPaletteActions() { to={`/settings/${organization.slug}/projects/${project.slug}/keys/`} /> ))} - + {hasDsnLookup && ( - data.map((item, i) => renderAsyncResult(item, i)) } - + )} - + - + }} onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} @@ -314,7 +314,7 @@ export function GlobalCommandPaletteActions() { window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') } /> - { return queryOptions({ @@ -353,11 +353,11 @@ export function GlobalCommandPaletteActions() { {(data: CommandPaletteAsyncResult[]) => data.map((item, i) => renderAsyncResult(item, i)) } - - + + - - }}> + + }}> { @@ -382,8 +382,8 @@ export function GlobalCommandPaletteActions() { addSuccessMessage(t('Theme preference saved: Dark')); }} /> - - + + ); } diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 8dd1761aee6d01..5f5b11de3840a9 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -18,14 +18,14 @@ import {CommandPaletteDemo} from './__stories__/components'; ## Basic Usage -Use `CMDKAction` and `CMDKGroup` JSX components inside your page or feature component to register contextual actions with the global command palette. Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while your component is rendered. This is ideal for page‑specific shortcuts. +Use `CMDKAction` JSX components inside your page or feature component to register contextual actions with the global command palette. Actions are registered on mount and automatically unregistered on unmount, so they only appear in the palette while your component is rendered. This is ideal for page‑specific shortcuts. Wrap your tree in `CommandPaletteProvider` and place the `CommandPalette` UI component wherever you want the dialog to render. Then declare actions anywhere inside the provider: - **Navigation actions**: Provide a `to` prop to navigate when selected. - **Callback actions**: Provide an `onAction` handler to execute when selected. -- **Grouped actions**: Wrap `CMDKAction` children inside a `CMDKGroup` to show a second level. Selecting the parent reveals its children. -- **Async actions**: Provide a `resource` prop to a `CMDKGroup` and use the render-prop children signature to generate actions from fetched data. +- **Grouped actions**: Nest `CMDKAction` children inside another `CMDKAction` to show a second level. Selecting the parent reveals its children. +- **Async actions**: Provide a `resource` prop and use the render-prop children signature to fetch and populate async results. @@ -39,7 +39,7 @@ import {useCallback} from 'react'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import { CMDKAction, - CMDKGroup, + CMDKAction, CommandPaletteProvider, } from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; @@ -74,16 +74,16 @@ function YourComponent() { /> {/* Grouped actions */} - + addSuccessMessage('Child action executed')} /> - + {/* The command palette UI — also accepts inline actions via children */} - + addSuccessMessage('Select all')} @@ -92,7 +92,7 @@ function YourComponent() { display={{label: 'Deselect all'}} onAction={() => addSuccessMessage('Deselect all')} /> - + ); @@ -101,12 +101,12 @@ function YourComponent() { ## Async / Resource Actions -`CMDKGroup` accepts a `resource` prop — a function that takes the current search query and returns a TanStack Query options object. The `children` prop becomes a render function that receives the fetched results: +`CMDKAction` accepts a `resource` prop — a function that takes the current search query and returns a TanStack Query options object. The `children` prop becomes a render function that receives the fetched results: ```tsx import type {CommandPaletteAsyncResult} from 'sentry/components/commandPalette/types'; - ({ queryKey: ['/projects/', {query}], @@ -121,5 +121,5 @@ import type {CommandPaletteAsyncResult} from 'sentry/components/commandPalette/t )) } -; +; ``` From 9d70bc8d61bffb970d07ca42f55ab83bd5191b3c Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 9 Apr 2026 04:31:54 +0200 Subject: [PATCH 25/43] fix(cmdk): Omit empty groups and reset scroll on query change (#112325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small fixes for the JSX-powered command palette. **Empty groups are now omitted from the list.** A `CMDKGroup` that renders no children ends up with zero registered nodes in the collection. In browse mode, `flattenActions` was treating such nodes as leaf actions and showing them. These are headless section headers with nothing to show — they should be invisible. The fix skips any node that has no children and no executable action (`to` / `onAction`). **Scroll resets to the top when the query changes.** Typing a new search query now scrolls the results list back to the top. The tricky part: `ResultsList` is not the actual scroll element — `ListBox` creates its own inner `div` that it hands to TanStack Virtual as the scroll container. The fix adds a `scrollContainerRef` prop to `ListBox` that gets merged into that inner container's refs, then wires it up in the `onChange` handler. Stacked on #112262. Co-authored-by: Claude Sonnet 4.6 --- .../commandPalette/ui/commandPalette.spec.tsx | 15 +++++++++++++++ .../commandPalette/ui/commandPalette.tsx | 13 ++++++++++++- .../core/compactSelect/listBox/index.tsx | 12 +++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index f31510c64c8aee..89bd4916c5fb91 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -577,6 +577,21 @@ describe('CommandPalette', () => { expect(screen.getAllByRole('option', {name: 'Action B'})).toHaveLength(1); }); + it('a group with no children is omitted from the list', async () => { + render( + + + + + + ); + + expect( + await screen.findByRole('option', {name: 'Real Action'}) + ).toBeInTheDocument(); + expect(screen.queryByRole('option', {name: 'Empty Group'})).not.toBeInTheDocument(); + }); + it('direct CMDKAction registrations outside slots are not duplicated', async () => { render( diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 243d3adabad2b1..a9e7489b791958 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} from 'react'; +import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react'; import {preload} from 'react-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; @@ -223,6 +223,8 @@ export function CommandPalette(props: CommandPaletteProps) { [actions, analytics, dispatch, props] ); + const resultsListRef = useRef(null); + const debouncedQuery = useDebouncedValue(state.query, 300); const isLoading = state.query.length > 0 && debouncedQuery !== state.query; @@ -287,6 +289,9 @@ 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) { @@ -364,6 +369,7 @@ export function CommandPalette(props: CommandPaletteProps) { overflow="auto" > true} overlayIsOpen @@ -448,6 +454,11 @@ function flattenActions( const results: CMDKFlatItem[] = []; for (const node of nodes) { const isGroup = node.children.length > 0; + // Skip groups that have no children and no executable action — they are + // empty section headers (e.g. a CMDKGroup whose children didn't render). + if (!isGroup && !('to' in node) && !('onAction' in node)) { + continue; + } results.push({...node, listItemType: isGroup ? 'section' : 'action'}); if (isGroup) { for (const child of node.children) { diff --git a/static/app/components/core/compactSelect/listBox/index.tsx b/static/app/components/core/compactSelect/listBox/index.tsx index 781e9e66fd1d35..fbd18bbe64c49d 100644 --- a/static/app/components/core/compactSelect/listBox/index.tsx +++ b/static/app/components/core/compactSelect/listBox/index.tsx @@ -82,6 +82,11 @@ interface ListBoxProps */ overlayIsOpen?: boolean; ref?: React.Ref; + /** + * Ref forwarded to the inner scroll container div (the element the virtualizer + * uses as its scroll element). Useful for callers that need to reset scrollTop. + */ + scrollContainerRef?: React.Ref; /** * Whether the select has a search input field. */ @@ -139,6 +144,7 @@ export function ListBox({ showDetails = true, onAction, virtualized, + scrollContainerRef, className, ...props }: ListBoxProps) { @@ -188,15 +194,15 @@ export function ListBox({ const virtualizer = useVirtualizedItems({listItems, virtualized, size}); const refs = useMemo(() => { - const scrollContainerRef = (scrollContainer: HTMLDivElement | null) => { + const overflowTracker = (scrollContainer: HTMLDivElement | null) => { if (hasEverOverflowed || listItems.length === 0 || !scrollContainer) { return; } setHasEverOverflowed(scrollContainer.scrollHeight > scrollContainer.clientHeight); }; - return mergeRefs(scrollContainerRef, virtualizer.scrollElementRef); - }, [hasEverOverflowed, virtualizer.scrollElementRef, listItems]); + return mergeRefs(overflowTracker, virtualizer.scrollElementRef, scrollContainerRef); + }, [hasEverOverflowed, virtualizer.scrollElementRef, listItems, scrollContainerRef]); return ( From 497dd7e47cad3e5a34414c521fc5d2d04328c79f Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 04:45:42 +0200 Subject: [PATCH 26/43] feat(cmdk): Invoke onAction callback for actions with children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CMDKAction has both an onAction callback and children, selecting it now invokes the callback immediately and keeps the modal open so the user can choose a secondary action from the children. Previously, actions with children were treated purely as navigation groups — the onAction callback was silently ignored. Now the callback fires before the palette pushes into the child list. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/ui/commandPalette.spec.tsx | 60 ++++++++++++++++++- .../commandPalette/ui/commandPalette.tsx | 5 ++ .../components/commandPalette/ui/modal.tsx | 5 ++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 89bd4916c5fb91..e12a58d92d0500 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -366,6 +366,64 @@ describe('CommandPalette', () => { }); }); + describe('action with onAction and children', () => { + it('invokes callback, keeps modal open, and then shows children for secondary selection', async () => { + const primaryCallback = jest.fn(); + const secondaryCallback = jest.fn(); + const closeSpy = jest.spyOn(modalActions, 'closeModal'); + + // 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) => { + if ('onAction' in action) { + action.onAction(); + if (action.children.length > 0) { + return; + } + } + closeModal(); + }; + + // Top-level groups become section headers (disabled), so the action-with-callback + // must be a child item — matching how "Parent Group Action" works in allActions. + render( + + + + + + + + + ); + + // Select the primary action (has both onAction and children) + await userEvent.click(await screen.findByRole('option', {name: 'Primary Action'})); + + // Callback should have been invoked + expect(primaryCallback).toHaveBeenCalledTimes(1); + + // Modal must remain open — no close call yet + expect(closeSpy).not.toHaveBeenCalled(); + + // The palette should have pushed into the children + expect( + await screen.findByRole('option', {name: 'Secondary Action'}) + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', {name: 'Primary Action'}) + ).not.toBeInTheDocument(); + + // Selecting the secondary action should invoke its callback and close the modal + await userEvent.click(screen.getByRole('option', {name: 'Secondary Action'})); + expect(secondaryCallback).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('query restoration', () => { it('drilling into a group clears the active query', async () => { render(); @@ -580,7 +638,7 @@ describe('CommandPalette', () => { it('a group with no children is omitted from the list', async () => { render( - + diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index a9e7489b791958..82e3d6665fcaf9 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -212,6 +212,11 @@ export function CommandPalette(props: CommandPaletteProps) { if (action.children.length > 0) { analytics.recordGroupAction(action, resultIndex); + 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); + } dispatch({type: 'push action', key: action.key, label: action.display.label}); return; } diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 581e8c93255a13..b16b6077062f3b 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -20,6 +20,11 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { navigate(normalizeUrl(String(action.to))); } else if ('onAction' in action) { action.onAction(); + // When the action has children, the palette will push into them so the + // user can select a secondary action — keep the modal open. + if (action.children.length > 0) { + return; + } } closeModal(); }, From e2b0710b8412b3490a204cb1b50ccf687ae8705e Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 04:54:36 +0200 Subject: [PATCH 27/43] fix(cmdk): Use render prop closeModal to properly reset open state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal was calling the imported closeModal() from actionCreators/modal directly, which goes straight to ModalStore.closeModal() and bypasses GlobalModal's internal closeModal wrapper. That wrapper is the only place options.onClose is invoked — which is the closeCommandPaletteModal callback that dispatches {type: 'toggle modal'} to reset state.open. With state.open stuck at true after an action closed the palette, the next hotkey press would see open===true and treat it as a close request rather than an open request, requiring a second press to actually reopen. Fix by destructuring closeModal from ModalRenderProps and using that instead of the imported version. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/ui/modal.spec.tsx | 113 ++++++++++++++++++ .../components/commandPalette/ui/modal.tsx | 5 +- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 static/app/components/commandPalette/ui/modal.spec.tsx diff --git a/static/app/components/commandPalette/ui/modal.spec.tsx b/static/app/components/commandPalette/ui/modal.spec.tsx new file mode 100644 index 00000000000000..65b8832aa59fc7 --- /dev/null +++ b/static/app/components/commandPalette/ui/modal.spec.tsx @@ -0,0 +1,113 @@ +jest.unmock('lodash/debounce'); + +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({count}: {count: number}) => { + const virtualItems = Array.from({length: count}, (_, index) => ({ + key: index, + index, + start: index * 48, + size: 48, + lane: 0, + })); + return { + getVirtualItems: () => virtualItems, + getTotalSize: () => count * 48, + measureElement: jest.fn(), + measure: jest.fn(), + }; + }, +})); + +// Avoid pulling in the full global actions tree (needs org context, feature flags, etc.) +jest.mock('sentry/components/commandPalette/ui/commandPaletteGlobalActions', () => ({ + GlobalCommandPaletteActions: () => null, +})); + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import { + CMDKAction, + CommandPaletteProvider, +} from 'sentry/components/commandPalette/ui/cmdk'; +import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import CommandPaletteModal from 'sentry/components/commandPalette/ui/modal'; +import { + makeCloseButton, + makeClosableHeader, + ModalBody, + ModalFooter, +} from 'sentry/components/globalModal/components'; + +/** + * Returns a minimal but valid ModalRenderProps with a jest.fn() as closeModal. + * Header and CloseButton are wired to the same spy so all close paths are tracked. + */ +function makeRenderProps(closeModal: jest.Mock) { + return { + closeModal, + Body: ModalBody, + Footer: ModalFooter, + Header: makeClosableHeader(closeModal), + CloseButton: makeCloseButton(closeModal), + }; +} + +describe('CommandPaletteModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('calls the render prop closeModal (not the imported one) when a leaf action is selected', async () => { + // This test guards against the regression where modal.tsx called the + // imported closeModal() directly. That bypasses GlobalModal's internal + // closeModal(), so options.onClose never fires and state.open stays true — + // causing the hotkey to need two presses to reopen the palette. + const closeModalSpy = jest.fn(); + const onActionSpy = jest.fn(); + + render( + + + + + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'Leaf Action'})); + + // The action callback must fire … + expect(onActionSpy).toHaveBeenCalledTimes(1); + // … and the render-prop closeModal (which triggers options.onClose and + // resets state.open) must be the one that is called, not an internally + // imported closeModal that skips the onClose hook. + expect(closeModalSpy).toHaveBeenCalledTimes(1); + }); + + it('does not call closeModal when an action with children is selected', async () => { + // Actions with children push into secondary actions — the modal stays open. + const closeModalSpy = jest.fn(); + const onActionSpy = jest.fn(); + + render( + + + + + + + + + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'Parent Action'})); + + expect(onActionSpy).toHaveBeenCalledTimes(1); + // Modal must remain open so the user can select a secondary action + expect(closeModalSpy).not.toHaveBeenCalled(); + // Secondary action is now visible + expect(await screen.findByRole('option', {name: 'Child Action'})).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index b16b6077062f3b..d1f6cfe09defa6 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -2,7 +2,6 @@ import {useCallback} from 'react'; import {css} from '@emotion/react'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {closeModal} 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'; @@ -11,7 +10,7 @@ import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; -export default function CommandPaletteModal({Body}: ModalRenderProps) { +export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { const navigate = useNavigate(); const handleSelect = useCallback( @@ -28,7 +27,7 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) { } closeModal(); }, - [navigate] + [navigate, closeModal] ); return ( From dd0b8b925c05528fdb0d8c72d00b56176a53b5fb Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 05:02:41 +0200 Subject: [PATCH 28/43] perf(cmdk): Score each candidate field individually in scoreNode Instead of joining label, details, and keywords into a single string and running fzf once, score each field independently and return the best match. This has two benefits: - Avoids false cross-field subsequence matches that could arise when the tail of one field and the head of the next happen to satisfy a pattern across the join boundary. - Allows fzf's built-in exact-match bonus to fire naturally (e.g. when the query equals the label exactly), without needing a manual boost at the call site. Co-Authored-By: Claude Sonnet 4 --- .../commandPalette/ui/commandPalette.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 82e3d6665fcaf9..5900bc7cb7b793 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -420,9 +420,21 @@ function scoreNode( const label = node.display.label; const details = node.display.details ?? ''; const keywords = node.keywords ?? []; - const candidates = [label, details, ...keywords].join(' '); - const result = fzf(candidates, query, false); - return {matched: result.end !== -1, score: result.score}; + + // Score each field independently and take the best result. This lets + // fzf's built-in exact-match bonus fire naturally (e.g. query === label) + // and avoids false cross-field subsequence matches from string concatenation. + let best = -Infinity; + let matched = false; + for (const candidate of [label, details, ...keywords]) { + if (!candidate) continue; + const result = fzf(candidate, query, false); + if (result.end !== -1 && result.score > best) { + best = result.score; + matched = true; + } + } + return {matched, score: matched ? best : 0}; } function scoreTree( From f1a1a06c4aa44c303f798a904373fcf8b01eec85 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 05:12:36 +0200 Subject: [PATCH 29/43] =?UTF-8?q?fix(cmdk):=20Address=20bugbot=20findings?= =?UTF-8?q?=20=E2=80=94=20admin=20actions,=20DSN=20icon,=20String=20coerci?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore admin/superuser action group (Open _admin, Open org in _admin, Open Superuser Modal, Exit Superuser) for staff users; these existed in the old useGlobalCommandPaletteActions hook but were not ported to the new GlobalCommandPaletteActions component - Add missing IconList to DSN_ICONS so the third getDsnNavTargets result (Client Keys/DSN) renders with an icon - Remove String(action.to) coercion in modal.tsx and the test helper; both normalizeUrl and navigate already accept LocationDescriptor, and String() would corrupt object-form descriptors to "[object Object]" - Remove redundant CommandPaletteSlot.Provider from the test helper since CommandPaletteProvider already wraps children in one Co-Authored-By: Claude --- .../commandPalette/ui/commandPalette.spec.tsx | 8 ++- .../ui/commandPaletteGlobalActions.tsx | 51 ++++++++++++++++++- .../components/commandPalette/ui/modal.tsx | 2 +- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index e12a58d92d0500..c0147e89d61147 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -86,7 +86,7 @@ function GlobalActionsComponent({ const handleAction = useCallback( (action: CollectionTreeNode) => { if ('to' in action) { - navigate(String(action.to)); + navigate(action.to); } else if ('onAction' in action) { action.onAction(); } @@ -97,10 +97,8 @@ function GlobalActionsComponent({ return ( - - - {children} - + + {children} ); } diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 78a26d1d0f4456..31c7dab78a98c3 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -5,6 +5,7 @@ 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, CommandPaletteAsyncResult, @@ -23,6 +24,7 @@ import { IconGithub, IconGraph, IconIssues, + IconList, IconLock, IconOpen, IconSearch, @@ -32,10 +34,12 @@ import { } from 'sentry/icons'; import {t} from 'sentry/locale'; import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {queryOptions} from 'sentry/utils/queryClient'; +import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; +import {QUERY_API_CLIENT, queryOptions, useMutation} from 'sentry/utils/queryClient'; import {useMutateUserOptions} from 'sentry/utils/useMutateUserOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; +import {useUser} from 'sentry/utils/useUser'; import {useGetStarredDashboards} from 'sentry/views/dashboards/hooks/useGetStarredDashboards'; import {AGENTS_LANDING_SUB_PATH} from 'sentry/views/insights/pages/agents/settings'; import {BACKEND_LANDING_SUB_PATH} from 'sentry/views/insights/pages/backend/settings'; @@ -52,6 +56,7 @@ import {CommandPaletteSlot} from './commandPaletteSlot'; const DSN_ICONS: React.ReactElement[] = [ , , + , ]; const helpSearch = new SentryGlobalSearch(['docs', 'develop']); @@ -72,11 +77,17 @@ function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { */ export function GlobalCommandPaletteActions() { const organization = useOrganization(); + const user = useUser(); const hasDsnLookup = organization.features.includes('cmd-k-dsn-lookup'); const {projects} = useProjects(); const {mutateAsync: mutateUserOptions} = useMutateUserOptions(); const {starredViews} = useStarredIssueViews(); const {data: starredDashboards = []} = useGetStarredDashboards(); + const {mutate: exitSuperuser} = useMutation({ + mutationFn: () => + QUERY_API_CLIENT.requestPromise('/auth/superuser/', {method: 'DELETE'}), + onSuccess: () => window.location.reload(), + }); const prefix = `/organizations/${organization.slug}`; @@ -384,6 +395,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()} + /> + )} + + )} ); } diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index d1f6cfe09defa6..11303e4982a394 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -16,7 +16,7 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps const handleSelect = useCallback( (action: CollectionTreeNode) => { if ('to' in action) { - navigate(normalizeUrl(String(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 From c56f2d260277f0e9a3fe5965102af27ac3aa182f Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 05:22:17 +0200 Subject: [PATCH 30/43] fix(cmdk): Remove String() coercion in story component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same no-base-to-string fix as the modal and test helper — pass action.to directly to normalizeUrl which already accepts LocationDescriptor. Co-Authored-By: Claude --- static/app/components/commandPalette/__stories__/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index ea0b08af142a3e..e7d81df6b38a6f 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -15,7 +15,7 @@ export function CommandPaletteDemo() { const handleAction = useCallback( (action: CollectionTreeNode) => { if ('to' in action) { - navigate(normalizeUrl(String(action.to))); + navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { action.onAction(); } From 75087572bd63950d070cf77bffd34599f126a97f Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 9 Apr 2026 11:23:24 +0200 Subject: [PATCH 31/43] fix(slot): Render nothing when no outlet is registered (#112568) Render slot consumer content only after an outlet element is available. Previously the slot consumer rendered its children in place until the outlet mounted, then switched over to a portal. That caused a flash of content at the wrong DOM position and changed the rendered element type under React, which reset component identity across outlet lifecycle changes. This makes the consumer return `null` until the outlet is ready and updates the slot tests to match that behavior. Refs GH-112564 Co-authored-by: OpenAI Codex --- static/app/components/core/slot/slot.spec.tsx | 10 +++++----- static/app/components/core/slot/slot.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/static/app/components/core/slot/slot.spec.tsx b/static/app/components/core/slot/slot.spec.tsx index d23b461d357cda..16b2a26123fb16 100644 --- a/static/app/components/core/slot/slot.spec.tsx +++ b/static/app/components/core/slot/slot.spec.tsx @@ -11,7 +11,7 @@ describe('slot', () => { expect(SlotModule.Fallback).toBeDefined(); }); - it('renders children in place when no Outlet is registered', () => { + it('renders nothing when no Outlet is registered', () => { const SlotModule = slot(['header'] as const); render( @@ -22,7 +22,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 +44,7 @@ describe('slot', () => { ); }); - it('multiple slot consumers render their children independently', () => { + it('multiple slot consumers render nothing independently when no Outlet is registered', () => { const SlotModule = slot(['a', 'b'] as const); render( @@ -58,8 +58,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..62467617aa4758 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,10 +149,12 @@ function makeSlotConsumer( ); + const element = state[name]?.element; + if (!element) { - // Render in place as a fallback when no target element is registered yet - return wrappedChildren; + return null; } + return createPortal(wrappedChildren, element); } From fa3d675589c8eeda72db72811be1285df136529a Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 20:13:28 +0200 Subject: [PATCH 32/43] remove unused exports --- static/app/components/commandPalette/ui/collection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index a16b3d0fd1b9ca..1f143add2e7925 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -20,7 +20,7 @@ export type CollectionTreeNode = { parent: string | null; } & T; -export interface CollectionStore { +interface CollectionStore { getSnapshot: () => Map>; register: (node: StoredNode) => void; subscribe: (callback: () => void) => () => void; @@ -28,7 +28,7 @@ export interface CollectionStore { unregister: (key: string) => void; } -export interface CollectionInstance { +interface CollectionInstance { Context: React.Context; Provider: (props: {children: React.ReactNode}) => React.ReactElement; useRegisterNode: (data: T) => string; From e198a91bc39344dc79041ff41fe5298609a91e1e Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 20:36:02 +0200 Subject: [PATCH 33/43] ref(cmdk): Remove ActionsToJSX adapter and CommandPaletteAsyncResult alias Replace the ActionsToJSX bridge component in tests with direct JSX CMDKAction registrations. Remove the CommandPaletteAsyncResult type alias in favour of CommandPaletteAction, which is the same shape and avoids the unnecessary split. Co-Authored-By: Claude Sonnet 4.6 --- .../app/components/commandPalette/types.tsx | 14 +- .../app/components/commandPalette/ui/cmdk.tsx | 8 +- .../commandPalette/ui/commandPalette.spec.tsx | 248 +++++++++--------- .../ui/commandPaletteGlobalActions.tsx | 14 +- 4 files changed, 126 insertions(+), 158 deletions(-) diff --git a/static/app/components/commandPalette/types.tsx b/static/app/components/commandPalette/types.tsx index 9a5c0856194b50..ba4167679fc362 100644 --- a/static/app/components/commandPalette/types.tsx +++ b/static/app/components/commandPalette/types.tsx @@ -16,19 +16,7 @@ interface Action { keywords?: string[]; } -/** - * Actions that can be returned from an async resource query. - */ -export type CommandPaletteAsyncResult = - | CommandPaletteActionLink - | CommandPaletteActionCallback; - -export type CMDKQueryOptions = UseQueryOptions< - any, - Error, - CommandPaletteAsyncResult[], - any ->; +export type CMDKQueryOptions = UseQueryOptions; export interface CommandPaletteActionLink extends Action { /** Navigate to a route when selected */ diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 5847fb97e04242..17f9f9b488d6a6 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -1,10 +1,8 @@ import {useQuery} from '@tanstack/react-query'; import type {LocationDescriptor} from 'history'; -import type { - CMDKQueryOptions, - CommandPaletteAsyncResult, -} from 'sentry/components/commandPalette/types'; +import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; +import type {CMDKQueryOptions} from 'sentry/components/commandPalette/types'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {makeCollection} from './collection'; @@ -64,7 +62,7 @@ export function CommandPaletteProvider({children}: {children: React.ReactNode}) interface CMDKActionProps { display: DisplayProps; - children?: React.ReactNode | ((data: CommandPaletteAsyncResult[]) => React.ReactNode); + children?: React.ReactNode | ((data: CommandPaletteAction[]) => React.ReactNode); keywords?: string[]; onAction?: () => void; resource?: (query: string) => CMDKQueryOptions; diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index c0147e89d61147..8162c82371c524 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -24,7 +24,6 @@ jest.mock('@tanstack/react-virtual', () => ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; -import type {CommandPaletteAction} from 'sentry/components/commandPalette/types'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; @@ -33,54 +32,7 @@ import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {useNavigate} from 'sentry/utils/useNavigate'; -/** - * Converts the old-style CommandPaletteAction[] fixture format into the new - * JSX registration components so tests don't need to be fully rewritten. - */ -function ActionsToJSX({actions}: {actions: CommandPaletteAction[]}) { - return ( - - {actions.map((action, i) => { - if ('actions' in action) { - return ( - - - - ); - } - if ('to' in action) { - return ( - - ); - } - if ('onAction' in action) { - return ( - - ); - } - return null; - })} - - ); -} - -function GlobalActionsComponent({ - actions, - children, -}: { - actions: CommandPaletteAction[]; - children?: React.ReactNode; -}) { +function GlobalActionsComponent({children}: {children?: React.ReactNode}) { const navigate = useNavigate(); const handleAction = useCallback( @@ -97,40 +49,27 @@ function GlobalActionsComponent({ return ( - - {children} + {children} + ); } const onChild = jest.fn(); -const allActions: CommandPaletteAction[] = [ - { - to: '/target/', - display: { - label: 'Go to route', - }, - }, - { - to: '/other/', - display: {label: 'Other'}, - }, - { - display: {label: 'Parent Label'}, - actions: [ - { - display: {label: 'Parent Group Action'}, - actions: [ - { - onAction: onChild, - display: {label: 'Child Action'}, - }, - ], - }, - ], - }, -]; +function AllActions() { + return ( + + + + + + + + + + ); +} describe('CommandPalette', () => { beforeEach(() => { @@ -139,7 +78,11 @@ describe('CommandPalette', () => { it('clicking a link item navigates and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const {router} = render(); + const {router} = render( + + + + ); await userEvent.click(await screen.findByRole('option', {name: 'Go to route'})); await waitFor(() => expect(router.location.pathname).toBe('/target/')); @@ -148,7 +91,11 @@ describe('CommandPalette', () => { it('ArrowDown to a link item then Enter navigates and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const {router} = render(); + const {router} = render( + + + + ); await screen.findByRole('textbox', {name: 'Search commands'}); // First item should already be highlighted, arrow down will go highlight "other" await userEvent.keyboard('{ArrowDown}{Enter}'); @@ -159,7 +106,11 @@ describe('CommandPalette', () => { it('clicking action with children shows sub-items, backspace returns', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - render(); + render( + + + + ); // Open children await userEvent.click( @@ -191,7 +142,11 @@ describe('CommandPalette', () => { it('clicking child sub-item runs onAction and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - render(); + render( + + + + ); await userEvent.click( await screen.findByRole('option', {name: 'Parent Group Action'}) ); @@ -203,7 +158,11 @@ describe('CommandPalette', () => { describe('search', () => { it('typing a query filters results to matching items only', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'route'); @@ -217,7 +176,11 @@ describe('CommandPalette', () => { }); it('non-matching items are not shown', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'xyzzy'); @@ -225,7 +188,11 @@ describe('CommandPalette', () => { }); it('clearing the query restores all top-level items', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'route'); expect( @@ -242,7 +209,11 @@ describe('CommandPalette', () => { }); it('child actions are not shown when query is empty', async () => { - render(); + render( + + + + ); await screen.findByRole('option', {name: 'Parent Group Action'}); expect( @@ -251,7 +222,11 @@ describe('CommandPalette', () => { }); it('child actions are directly searchable without drilling into the group', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'child'); @@ -261,7 +236,11 @@ describe('CommandPalette', () => { }); it('preserves spaces in typed query', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'test query'); @@ -269,7 +248,11 @@ describe('CommandPalette', () => { }); it('search is case-insensitive', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'ROUTE'); @@ -279,17 +262,12 @@ describe('CommandPalette', () => { }); it('actions are ranked by match quality — better matches appear first', async () => { - const actions: CommandPaletteAction[] = [ - { - to: '/a/', - display: {label: 'Something with issues buried'}, - }, - { - to: '/b/', - display: {label: 'Issues'}, - }, - ]; - render(); + render( + + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'issues'); @@ -301,17 +279,14 @@ describe('CommandPalette', () => { }); it('top-level actions rank before child actions when both match the query', async () => { - const actions: CommandPaletteAction[] = [ - { - display: {label: 'Group'}, - actions: [{to: '/child/', display: {label: 'Issues child'}}], - }, - { - to: '/top/', - display: {label: 'Issues'}, - }, - ]; - render(); + render( + + + + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'issues'); @@ -323,14 +298,15 @@ describe('CommandPalette', () => { }); it('actions with matching keywords are included in results', async () => { - const actions: CommandPaletteAction[] = [ - { - to: '/shortcuts/', - display: {label: 'Keyboard shortcuts'}, - keywords: ['hotkeys', 'keybindings'], - }, - ]; - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.type(input, 'hotkeys'); @@ -340,16 +316,14 @@ describe('CommandPalette', () => { }); it("searching within a drilled-in group filters that group's children", async () => { - const actions: CommandPaletteAction[] = [ - { - display: {label: 'Theme'}, - actions: [ - {onAction: jest.fn(), display: {label: 'Light'}}, - {onAction: jest.fn(), display: {label: 'Dark'}}, - ], - }, - ]; - render(); + render( + + + + + + + ); // Drill into the group await userEvent.click(await screen.findByRole('option', {name: 'Theme'})); @@ -424,7 +398,11 @@ describe('CommandPalette', () => { describe('query restoration', () => { it('drilling into a group clears the active query', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); // Type a query that shows the group in search results @@ -438,7 +416,11 @@ describe('CommandPalette', () => { }); it('Backspace from a drilled group restores the query that was active before drilling in', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); // Type a query, then drill into the group that appears in search results @@ -455,7 +437,11 @@ describe('CommandPalette', () => { }); it('clicking the back button from a drilled group restores the query that was active before drilling in', async () => { - render(); + render( + + + + ); const input = await screen.findByRole('textbox', {name: 'Search commands'}); // Type a query, then drill into the group that appears in search results diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 31c7dab78a98c3..e76777a778cd70 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -8,7 +8,7 @@ import {openInviteMembersModal} from 'sentry/actionCreators/modal'; import {openSudo} from 'sentry/actionCreators/sudoModal'; import type { CMDKQueryOptions, - CommandPaletteAsyncResult, + CommandPaletteAction, } from 'sentry/components/commandPalette/types'; import { DSN_PATTERN, @@ -61,7 +61,7 @@ const DSN_ICONS: React.ReactElement[] = [ const helpSearch = new SentryGlobalSearch(['docs', 'develop']); -function renderAsyncResult(item: CommandPaletteAsyncResult, index: number) { +function renderAsyncResult(item: CommandPaletteAction, index: number) { if ('to' in item) { return ; } @@ -295,9 +295,7 @@ export function GlobalCommandPaletteActions() { }); }} > - {(data: CommandPaletteAsyncResult[]) => - data.map((item, i) => renderAsyncResult(item, i)) - } + {data => data.map((item, i) => renderAsyncResult(item, i))} )} @@ -337,7 +335,7 @@ export function GlobalCommandPaletteActions() { {analyticsTags: ['source:command-palette']} ), select: data => { - const results: CommandPaletteAsyncResult[] = []; + const results = []; for (const index of data) { for (const hit of index.hits.slice(0, 3)) { results.push({ @@ -361,9 +359,7 @@ export function GlobalCommandPaletteActions() { }); }} > - {(data: CommandPaletteAsyncResult[]) => - data.map((item, i) => renderAsyncResult(item, i)) - } + {data => data.map((item, i) => renderAsyncResult(item, i))} From cd2aa33fbe051555c955ef5e629b8d3b703dbde2 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 20:59:41 +0200 Subject: [PATCH 34/43] ref(cmdk) move admin actions --- .../commandPalette/ui/commandPalette.tsx | 1 + .../ui/commandPaletteGlobalActions.tsx | 76 +++++++++---------- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 5900bc7cb7b793..6ce5e67cf3eae3 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -476,6 +476,7 @@ 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) { diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index e76777a778cd70..27a651aa913f7e 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -222,6 +222,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()} + /> + )} + + )} + }} @@ -391,44 +429,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()} - /> - )} - - )} ); } From 4e2dde129afc3e06764df3082d6351b3a9cec155 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 21:22:53 +0200 Subject: [PATCH 35/43] ref(cmdk) add link detection --- .../commandPalette/__stories__/components.tsx | 5 +- .../commandPalette/ui/commandPalette.spec.tsx | 110 +++++++++++++++++- .../commandPalette/ui/commandPalette.tsx | 69 +++++++++-- .../ui/commandPaletteGlobalActions.tsx | 34 ++---- .../components/commandPalette/ui/modal.tsx | 30 ++++- 5 files changed, 212 insertions(+), 36 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index e7d81df6b38a6f..a6f11881102c8c 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -13,7 +13,10 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + ( + action: CollectionTreeNode, + _options?: {modifierKeys?: {shiftKey: boolean}} + ) => { if ('to' in action) { navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 8162c82371c524..8bde4d3786afb5 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -1,4 +1,5 @@ import {Fragment, useCallback} from 'react'; +import type {LocationDescriptor} from 'history'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -30,15 +31,39 @@ 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 {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import {useNavigate} from 'sentry/utils/useNavigate'; +function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + + if (typeof resolved === 'string') { + return resolved; + } + + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} + function GlobalActionsComponent({children}: {children?: React.ReactNode}) { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => { if ('to' in action) { - navigate(action.to); + if (isExternalLocation(action.to) || options?.modifierKeys?.shiftKey) { + window.open(getLocationHref(action.to), '_blank', 'noreferrer'); + } else { + navigate(action.to); + } } else if ('onAction' in action) { action.onAction(); } @@ -104,6 +129,87 @@ describe('CommandPalette', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); + it('clicking a target blank to action opens an anchor and closes modal', async () => { + const closeSpy = jest.spyOn(modalActions, 'closeModal'); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + render( + + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'Open docs'})); + + expect(openSpy).toHaveBeenCalledWith( + 'https://docs.sentry.io', + '_blank', + 'noreferrer' + ); + expect(closeSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); + + it('shift-clicking an internal link opens it in a new tab and closes modal', async () => { + const closeSpy = jest.spyOn(modalActions, 'closeModal'); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + const {router} = render( + + + + ); + const initialPathname = router.location.pathname; + + await userEvent.keyboard('{Shift>}'); + await userEvent.click(await screen.findByRole('option', {name: 'Go to route'})); + await userEvent.keyboard('{/Shift}'); + + expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); + expect(router.location.pathname).toBe(initialPathname); + expect(closeSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); + + it('shift-enter on an internal link opens it in a new tab and closes modal', async () => { + const closeSpy = jest.spyOn(modalActions, 'closeModal'); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + const {router} = render( + + + + ); + const initialPathname = router.location.pathname; + + await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + + expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); + expect(router.location.pathname).toBe(initialPathname); + expect(closeSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); + + it('shows internal and external trailing link indicators for link actions', async () => { + render( + + + + + + + ); + + const internalAction = await screen.findByRole('option', {name: 'Internal'}); + const externalAction = await screen.findByRole('option', {name: 'External'}); + + expect( + internalAction.querySelector('[data-test-id="command-palette-link-indicator"]') + ).toHaveAttribute('data-link-type', 'internal'); + expect( + externalAction.querySelector('[data-test-id="command-palette-link-indicator"]') + ).toHaveAttribute('data-link-type', 'external'); + }); + it('clicking action with children shows sub-items, backspace returns', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); render( diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 6ce5e67cf3eae3..e46c3e185de26f 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -7,6 +7,7 @@ import {mergeProps} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; import {useTreeState} from '@react-stately/tree'; import {AnimatePresence, motion} from 'framer-motion'; +import type {LocationDescriptor} from 'history'; import errorIllustration from 'sentry-images/spot/computer-missing.svg'; @@ -30,9 +31,10 @@ 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, IconOpen, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import {fzf} from 'sentry/utils/search/fzf'; import type {Theme} from 'sentry/utils/theme'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; @@ -41,6 +43,22 @@ const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); const MotionContainer = motion.create(Container); +function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + + if (typeof resolved === 'string') { + return resolved; + } + + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} + function makeLeadingItemAnimation(theme: Theme) { return { initial: {scale: 0.95, opacity: 0}, @@ -65,7 +83,10 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { - onAction: (action: CollectionTreeNode) => void; + onAction: ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => void; children?: React.ReactNode; } @@ -202,7 +223,12 @@ export function CommandPalette(props: CommandPaletteProps) { }); const onActionSelection = useCallback( - (key: string | number | null) => { + ( + key: string | number | null, + options?: { + modifierKeys?: {shiftKey: boolean}; + } + ) => { const action = actions.find(a => a.key === key); if (!action) { return; @@ -215,7 +241,7 @@ 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, options); } dispatch({type: 'push action', key: action.key, label: action.display.label}); return; @@ -223,12 +249,13 @@ export function CommandPalette(props: CommandPaletteProps) { analytics.recordAction(action, resultIndex, ''); dispatch({type: 'trigger action'}); - props.onAction(action); + props.onAction(action, options); }, [actions, analytics, dispatch, props] ); const resultsListRef = useRef(null); + const openInNewTabRef = useRef(false); const debouncedQuery = useDebouncedValue(state.query, 300); @@ -319,7 +346,9 @@ export function CommandPalette(props: CommandPaletteProps) { } if (e.key === 'Enter' || e.key === 'Tab') { - onActionSelection(treeState.selectionManager.focusedKey); + onActionSelection(treeState.selectionManager.focusedKey, { + modifierKeys: {shiftKey: e.shiftKey}, + }); return; } }, @@ -383,7 +412,18 @@ export function CommandPalette(props: CommandPaletteProps) { aria-label={t('Search results')} selectionMode="none" shouldUseVirtualFocus - onAction={onActionSelection} + onMouseDownCapture={e => { + openInNewTabRef.current = e.shiftKey; + }} + onClickCapture={e => { + openInNewTabRef.current = e.shiftKey; + }} + onAction={key => { + onActionSelection(key, { + modifierKeys: {shiftKey: openInNewTabRef.current}, + }); + openInNewTabRef.current = false; + }} /> )} @@ -542,6 +582,20 @@ function flattenActions( } function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem { + const isExternal = 'to' in action ? isExternalLocation(action.to) : false; + const trailingItems = + 'to' in action ? ( + + + {isExternal ? : } + + + ) : undefined; + return { key: action.key, label: action.display.label, @@ -559,6 +613,7 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI {action.display.icon} ), + trailingItems, children: [], hideCheck: true, }; diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 27a651aa913f7e..c6b9697fab2607 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -26,7 +26,6 @@ import { IconIssues, IconList, IconLock, - IconOpen, IconSearch, IconSettings, IconStar, @@ -225,27 +224,20 @@ export function GlobalCommandPaletteActions() { {user.isStaff && ( }} + display={{label: t('Open _admin')}} keywords={[t('superuser')]} - onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} + to="/_admin/" /> , }} keywords={[t('superuser')]} - onAction={() => - window.open( - `/_admin/customers/${organization.slug}/`, - '_blank', - 'noreferrer' - ) - } + to={`/_admin/customers/${organization.slug}/`} /> {!isActiveSuperuser() && ( }} + display={{label: t('Open Superuser Modal')}} keywords={[t('superuser')]} onAction={() => openSudo({isSuperuser: true, needsReload: true})} /> @@ -341,25 +333,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/" /> typeof v === 'string' ), - onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + to: hit.url, }); } } diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 11303e4982a394..0c86f04e83720a 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -1,22 +1,48 @@ import {useCallback} from 'react'; import {css} from '@emotion/react'; +import type {LocationDescriptor} 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 {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; +function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + + if (typeof resolved === 'string') { + return resolved; + } + + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} + export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { const navigate = useNavigate(); const handleSelect = useCallback( - (action: CollectionTreeNode) => { + ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => { if ('to' in action) { - navigate(normalizeUrl(action.to)); + const normalizedTo = normalizeUrl(action.to); + if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) { + window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer'); + } else { + navigate(normalizedTo); + } } else if ('onAction' in action) { action.onAction(); // When the action has children, the palette will push into them so the From 81c1558f38726b72e2c2cec166f3b65f39493e08 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 21:29:19 +0200 Subject: [PATCH 36/43] feat(cmdk): Support tab-opening modifiers for links Add modifier key context to command palette actions so internal links can open in a new tab when selected with Shift. Keep link indicator rendering and selection handling aligned with the new action metadata. Split the tests so commandPalette covers modifier forwarding and modal covers the actual link-opening behavior. This avoids duplicating modal URL handling logic inside the palette tests. Co-Authored-By: OpenAI Codex --- .../commandPalette/ui/commandPalette.spec.tsx | 104 +++++++----------- .../commandPalette/ui/commandPalette.tsx | 30 ++++- .../commandPalette/ui/modal.spec.tsx | 48 +++++++- 3 files changed, 110 insertions(+), 72 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 8bde4d3786afb5..1fa17506f7930e 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -1,7 +1,12 @@ import {Fragment, useCallback} from 'react'; -import type {LocationDescriptor} from 'history'; -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; jest.unmock('lodash/debounce'); @@ -31,26 +36,18 @@ 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 {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; -import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import {useNavigate} from 'sentry/utils/useNavigate'; -function getLocationHref(to: LocationDescriptor): string { - const resolved = locationDescriptorToTo(to); - - if (typeof resolved === 'string') { - return resolved; - } - - return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; -} - -function isExternalLocation(to: LocationDescriptor): boolean { - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(getLocationHref(to), currentUrl.href); - return targetUrl.origin !== currentUrl.origin; -} - -function GlobalActionsComponent({children}: {children?: React.ReactNode}) { +function GlobalActionsComponent({ + children, + onAction, +}: { + children?: React.ReactNode; + onAction?: ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => void; +}) { const navigate = useNavigate(); const handleAction = useCallback( @@ -58,18 +55,16 @@ function GlobalActionsComponent({children}: {children?: React.ReactNode}) { action: CollectionTreeNode, options?: {modifierKeys?: {shiftKey: boolean}} ) => { - if ('to' in action) { - if (isExternalLocation(action.to) || options?.modifierKeys?.shiftKey) { - window.open(getLocationHref(action.to), '_blank', 'noreferrer'); - } else { - navigate(action.to); - } + if (onAction) { + onAction(action, options); + } else if ('to' in action) { + navigate(action.to); } else if ('onAction' in action) { action.onAction(); } closeModal(); }, - [navigate] + [navigate, onAction] ); return ( @@ -129,64 +124,43 @@ describe('CommandPalette', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); - it('clicking a target blank to action opens an anchor and closes modal', async () => { + it('shift-clicking an internal link forwards modifier keys and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + const onAction = jest.fn(); render( - - - - ); - - await userEvent.click(await screen.findByRole('option', {name: 'Open docs'})); - - expect(openSpy).toHaveBeenCalledWith( - 'https://docs.sentry.io', - '_blank', - 'noreferrer' - ); - expect(closeSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); - }); - - it('shift-clicking an internal link opens it in a new tab and closes modal', async () => { - const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - const {router} = render( - + ); - const initialPathname = router.location.pathname; - await userEvent.keyboard('{Shift>}'); - await userEvent.click(await screen.findByRole('option', {name: 'Go to route'})); - await userEvent.keyboard('{/Shift}'); + const option = await screen.findByRole('option', {name: 'Go to route'}); + fireEvent.mouseDown(option, {shiftKey: true}); + fireEvent.click(option, {shiftKey: true}); - expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); - expect(router.location.pathname).toBe(initialPathname); + expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), { + modifierKeys: {shiftKey: true}, + }); expect(closeSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); }); - it('shift-enter on an internal link opens it in a new tab and closes modal', async () => { + it('shift-enter on an internal link forwards modifier keys and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); - const {router} = render( - + const onAction = jest.fn(); + + render( + ); - const initialPathname = router.location.pathname; await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); - expect(router.location.pathname).toBe(initialPathname); + expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), { + modifierKeys: {shiftKey: true}, + }); expect(closeSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); }); it('shows internal and external trailing link indicators for link actions', async () => { diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index e46c3e185de26f..7b3312d9c75744 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'; @@ -255,7 +255,25 @@ export function CommandPalette(props: CommandPaletteProps) { ); const resultsListRef = useRef(null); - const openInNewTabRef = useRef(false); + const modifierKeysRef = useRef({shiftKey: false}); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + modifierKeysRef.current = {shiftKey: event.shiftKey}; + }; + + const handleKeyUp = (event: KeyboardEvent) => { + modifierKeysRef.current = {shiftKey: event.shiftKey}; + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); const debouncedQuery = useDebouncedValue(state.query, 300); @@ -413,16 +431,16 @@ export function CommandPalette(props: CommandPaletteProps) { selectionMode="none" shouldUseVirtualFocus onMouseDownCapture={e => { - openInNewTabRef.current = e.shiftKey; + modifierKeysRef.current = {shiftKey: e.shiftKey}; }} onClickCapture={e => { - openInNewTabRef.current = e.shiftKey; + modifierKeysRef.current = {shiftKey: e.shiftKey}; }} onAction={key => { onActionSelection(key, { - modifierKeys: {shiftKey: openInNewTabRef.current}, + modifierKeys: modifierKeysRef.current, }); - openInNewTabRef.current = false; + modifierKeysRef.current = {shiftKey: false}; }} /> diff --git a/static/app/components/commandPalette/ui/modal.spec.tsx b/static/app/components/commandPalette/ui/modal.spec.tsx index 65b8832aa59fc7..5ae6093fcaf8f7 100644 --- a/static/app/components/commandPalette/ui/modal.spec.tsx +++ b/static/app/components/commandPalette/ui/modal.spec.tsx @@ -23,7 +23,7 @@ jest.mock('sentry/components/commandPalette/ui/commandPaletteGlobalActions', () GlobalCommandPaletteActions: () => null, })); -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {fireEvent, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import { CMDKAction, @@ -110,4 +110,50 @@ describe('CommandPaletteModal', () => { // Secondary action is now visible expect(await screen.findByRole('option', {name: 'Child Action'})).toBeInTheDocument(); }); + + it('opens external links in a new tab', async () => { + const closeModalSpy = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + render( + + + + + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'External Link'})); + + expect(openSpy).toHaveBeenCalledWith( + 'https://docs.sentry.io', + '_blank', + 'noreferrer' + ); + expect(closeModalSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); + + it('opens internal links in a new tab when shift is held', async () => { + const closeModalSpy = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + render( + + + + + + + ); + + const option = await screen.findByRole('option', {name: 'Internal Link'}); + fireEvent.mouseDown(option, {shiftKey: true}); + fireEvent.click(option, {shiftKey: true}); + + expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); + expect(closeModalSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); }); From 53f7bdda6818e3ea7cd76aa065c5efcae1d2e9f0 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 21:34:21 +0200 Subject: [PATCH 37/43] test(cmdk): Align shift-link coverage with keyboard flow Remove the click-specific shift coverage and rely on the simpler global modifier key tracking in the command palette. This keeps the tests focused on the supported keyboard-driven selection path. Also drop the now-unneeded click capture handlers from the palette so modifier handling stays centralized in the key-state effect. Co-Authored-By: OpenAI Codex --- .../commandPalette/ui/commandPalette.spec.tsx | 28 +------------------ .../commandPalette/ui/commandPalette.tsx | 6 ---- .../commandPalette/ui/modal.spec.tsx | 9 +++--- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 1fa17506f7930e..abc2b2b4bbbe8c 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -1,12 +1,6 @@ import {Fragment, useCallback} from 'react'; -import { - fireEvent, - render, - screen, - userEvent, - waitFor, -} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; jest.unmock('lodash/debounce'); @@ -124,26 +118,6 @@ describe('CommandPalette', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); - it('shift-clicking an internal link forwards modifier keys and closes modal', async () => { - const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const onAction = jest.fn(); - - render( - - - - ); - - const option = await screen.findByRole('option', {name: 'Go to route'}); - fireEvent.mouseDown(option, {shiftKey: true}); - fireEvent.click(option, {shiftKey: true}); - - expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), { - modifierKeys: {shiftKey: true}, - }); - expect(closeSpy).toHaveBeenCalledTimes(1); - }); - it('shift-enter on an internal link forwards modifier keys and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); const onAction = jest.fn(); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 7b3312d9c75744..cc1e85f52c8102 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -430,12 +430,6 @@ export function CommandPalette(props: CommandPaletteProps) { aria-label={t('Search results')} selectionMode="none" shouldUseVirtualFocus - onMouseDownCapture={e => { - modifierKeysRef.current = {shiftKey: e.shiftKey}; - }} - onClickCapture={e => { - modifierKeysRef.current = {shiftKey: e.shiftKey}; - }} onAction={key => { onActionSelection(key, { modifierKeys: modifierKeysRef.current, diff --git a/static/app/components/commandPalette/ui/modal.spec.tsx b/static/app/components/commandPalette/ui/modal.spec.tsx index 5ae6093fcaf8f7..e2a9cddea4c69f 100644 --- a/static/app/components/commandPalette/ui/modal.spec.tsx +++ b/static/app/components/commandPalette/ui/modal.spec.tsx @@ -23,7 +23,7 @@ jest.mock('sentry/components/commandPalette/ui/commandPaletteGlobalActions', () GlobalCommandPaletteActions: () => null, })); -import {fireEvent, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import { CMDKAction, @@ -135,7 +135,7 @@ describe('CommandPaletteModal', () => { openSpy.mockRestore(); }); - it('opens internal links in a new tab when shift is held', async () => { + it('opens internal links in a new tab when shift-enter is used', async () => { const closeModalSpy = jest.fn(); const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); @@ -148,9 +148,8 @@ describe('CommandPaletteModal', () => { ); - const option = await screen.findByRole('option', {name: 'Internal Link'}); - fireEvent.mouseDown(option, {shiftKey: true}); - fireEvent.click(option, {shiftKey: true}); + await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); expect(closeModalSpy).toHaveBeenCalledTimes(1); From 072781173c7a811f26bac01f90d027a101c23498 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Thu, 9 Apr 2026 21:37:33 +0200 Subject: [PATCH 38/43] ref(cmdk) simplify keyboard --- .../commandPalette/ui/commandPaletteGlobalActions.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index c6b9697fab2607..61bc6afdc448a7 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -26,6 +26,7 @@ import { IconIssues, IconList, IconLock, + IconOpen, IconSearch, IconSettings, IconStar, @@ -224,20 +225,21 @@ export function GlobalCommandPaletteActions() { {user.isStaff && ( }} keywords={[t('superuser')]} to="/_admin/" /> , }} keywords={[t('superuser')]} to={`/_admin/customers/${organization.slug}/`} /> {!isActiveSuperuser() && ( }} keywords={[t('superuser')]} onAction={() => openSudo({isSuperuser: true, needsReload: true})} /> @@ -344,7 +346,7 @@ export function GlobalCommandPaletteActions() { to="https://github.com/getsentry/sentry" /> }} to="https://sentry.io/changelog/" /> Date: Thu, 9 Apr 2026 21:55:45 +0200 Subject: [PATCH 39/43] fix(cmdk): Restore new-tab behavior for admin links The /_admin/ actions were converted to 'to' props, causing isExternalLocation to return false (same origin) and navigate() to attempt client-side routing to Django-served admin pages. Switch both admin CMDKActions to onAction with window.open so they open in a new tab as originally intended. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/ui/commandPaletteGlobalActions.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 61bc6afdc448a7..63aba9a378f335 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -227,7 +227,7 @@ export function GlobalCommandPaletteActions() { }} keywords={[t('superuser')]} - to="/_admin/" + onAction={() => window.open('/_admin/', '_blank', 'noreferrer')} /> , }} keywords={[t('superuser')]} - to={`/_admin/customers/${organization.slug}/`} + onAction={() => + window.open( + `/_admin/customers/${organization.slug}/`, + '_blank', + 'noreferrer' + ) + } /> {!isActiveSuperuser() && ( Date: Fri, 10 Apr 2026 06:15:19 +0200 Subject: [PATCH 40/43] ref(cmdk): Extract getLocationHref and isExternalLocation to shared locationUtils Both commandPalette.tsx and modal.tsx contained identical copies of these helpers. Move them to a shared locationUtils.ts module to eliminate duplication. Co-Authored-By: Claude Sonnet 4 --- .../commandPalette/ui/commandPalette.tsx | 19 +--------------- .../commandPalette/ui/locationUtils.ts | 19 ++++++++++++++++ .../components/commandPalette/ui/modal.tsx | 22 ++++--------------- 3 files changed, 24 insertions(+), 36 deletions(-) create mode 100644 static/app/components/commandPalette/ui/locationUtils.ts diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index cc1e85f52c8102..ba565a58d6c8f9 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -7,7 +7,6 @@ import {mergeProps} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; import {useTreeState} from '@react-stately/tree'; import {AnimatePresence, motion} from 'framer-motion'; -import type {LocationDescriptor} from 'history'; import errorIllustration from 'sentry-images/spot/computer-missing.svg'; @@ -28,13 +27,13 @@ import { useCommandPaletteDispatch, useCommandPaletteState, } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; +import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils'; import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; -import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import {fzf} from 'sentry/utils/search/fzf'; import type {Theme} from 'sentry/utils/theme'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; @@ -43,22 +42,6 @@ const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); const MotionContainer = motion.create(Container); -function getLocationHref(to: LocationDescriptor): string { - const resolved = locationDescriptorToTo(to); - - if (typeof resolved === 'string') { - return resolved; - } - - return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; -} - -function isExternalLocation(to: LocationDescriptor): boolean { - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(getLocationHref(to), currentUrl.href); - return targetUrl.origin !== currentUrl.origin; -} - function makeLeadingItemAnimation(theme: Theme) { return { initial: {scale: 0.95, opacity: 0}, diff --git a/static/app/components/commandPalette/ui/locationUtils.ts b/static/app/components/commandPalette/ui/locationUtils.ts new file mode 100644 index 00000000000000..c67f339aed5da2 --- /dev/null +++ b/static/app/components/commandPalette/ui/locationUtils.ts @@ -0,0 +1,19 @@ +import type {LocationDescriptor} from 'history'; + +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; + +export function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + + if (typeof resolved === 'string') { + return resolved; + } + + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +export function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 0c86f04e83720a..37e217320a5ad2 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -1,33 +1,19 @@ import {useCallback} from 'react'; import {css} from '@emotion/react'; -import type {LocationDescriptor} 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 {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; -import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; +import { + getLocationHref, + isExternalLocation, +} from 'sentry/components/commandPalette/ui/locationUtils'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; -function getLocationHref(to: LocationDescriptor): string { - const resolved = locationDescriptorToTo(to); - - if (typeof resolved === 'string') { - return resolved; - } - - return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; -} - -function isExternalLocation(to: LocationDescriptor): boolean { - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(getLocationHref(to), currentUrl.href); - return targetUrl.origin !== currentUrl.origin; -} - export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { const navigate = useNavigate(); From a389f2774420392d3b124b5d18316b793a844923 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Fri, 10 Apr 2026 06:32:25 +0200 Subject: [PATCH 41/43] ref(cmdk): Remove onAction prop in favor of internal action execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandPalette now handles all action execution internally — navigating for 'to' actions (with shift-key new-tab support) and invoking the action-defined onAction callback for callback actions. Callers no longer need to implement action dispatch logic; the only integration point is the optional closeModal prop. This removes the indirection of consumers inspecting CollectionTreeNode shapes at call sites and centralises the execution model inside the component. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/__stories__/components.tsx | 30 +----- .../commandPalette/ui/commandPalette.spec.tsx | 95 ++++++------------- .../commandPalette/ui/commandPalette.tsx | 32 +++++-- .../components/commandPalette/ui/modal.tsx | 38 +------- .../useCommandPaletteActions.mdx | 22 +---- 5 files changed, 58 insertions(+), 159 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index a6f11881102c8c..c4cec99a9ba54f 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -1,31 +1,11 @@ -import {useCallback} from 'react'; - import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; -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 { + CMDKAction, + CommandPaletteProvider, +} from 'sentry/components/commandPalette/ui/cmdk'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; export function CommandPaletteDemo() { - const navigate = useNavigate(); - - const handleAction = useCallback( - ( - action: CollectionTreeNode, - _options?: {modifierKeys?: {shiftKey: boolean}} - ) => { - if ('to' in action) { - navigate(normalizeUrl(action.to)); - } else if ('onAction' in action) { - action.onAction(); - } - }, - [navigate] - ); - return ( @@ -39,7 +19,7 @@ export function CommandPaletteDemo() { onAction={() => addSuccessMessage('Child action executed')} /> - + ({ import {closeModal} from 'sentry/actionCreators/modal'; import * as modalActions from 'sentry/actionCreators/modal'; -import {CommandPaletteProvider} from 'sentry/components/commandPalette/ui/cmdk'; -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 { + CMDKAction, + CommandPaletteProvider, +} from 'sentry/components/commandPalette/ui/cmdk'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; -import {useNavigate} from 'sentry/utils/useNavigate'; - -function GlobalActionsComponent({ - children, - onAction, -}: { - children?: React.ReactNode; - onAction?: ( - action: CollectionTreeNode, - options?: {modifierKeys?: {shiftKey: boolean}} - ) => void; -}) { - const navigate = useNavigate(); - - const handleAction = useCallback( - ( - action: CollectionTreeNode, - options?: {modifierKeys?: {shiftKey: boolean}} - ) => { - if (onAction) { - onAction(action, options); - } else if ('to' in action) { - navigate(action.to); - } else if ('onAction' in action) { - action.onAction(); - } - closeModal(); - }, - [navigate, onAction] - ); +function GlobalActionsComponent({children}: {children?: React.ReactNode}) { return ( {children} - + ); } @@ -118,12 +89,12 @@ describe('CommandPalette', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); - it('shift-enter on an internal link forwards modifier keys and closes modal', async () => { + it('shift-enter on an internal link opens in a new tab and closes modal', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); - const onAction = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); render( - + ); @@ -131,10 +102,14 @@ describe('CommandPalette', () => { await screen.findByRole('textbox', {name: 'Search commands'}); await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), { - modifierKeys: {shiftKey: true}, - }); + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('target'), + '_blank', + 'noreferrer' + ); expect(closeSpy).toHaveBeenCalledTimes(1); + + openSpy.mockRestore(); }); it('shows internal and external trailing link indicators for link actions', async () => { @@ -398,18 +373,6 @@ describe('CommandPalette', () => { const secondaryCallback = jest.fn(); const closeSpy = jest.spyOn(modalActions, 'closeModal'); - // 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) => { - if ('onAction' in action) { - action.onAction(); - if (action.children.length > 0) { - return; - } - } - closeModal(); - }; - // Top-level groups become section headers (disabled), so the action-with-callback // must be a child item — matching how "Parent Group Action" works in allActions. render( @@ -422,7 +385,7 @@ describe('CommandPalette', () => { /> - + ); @@ -522,7 +485,7 @@ describe('CommandPalette', () => { - + ); @@ -541,9 +504,7 @@ describe('CommandPalette', () => { - ('onAction' in node ? node.onAction() : null)} - /> + ); @@ -559,7 +520,7 @@ describe('CommandPalette', () => { - + ); @@ -578,9 +539,7 @@ describe('CommandPalette', () => { - ('onAction' in node ? node.onAction() : null)} - /> + ); @@ -606,7 +565,7 @@ describe('CommandPalette', () => { - + ); @@ -630,7 +589,7 @@ describe('CommandPalette', () => { - + ); @@ -659,7 +618,7 @@ describe('CommandPalette', () => { render( - + @@ -678,7 +637,7 @@ describe('CommandPalette', () => { - + ); @@ -692,7 +651,7 @@ describe('CommandPalette', () => { render( - + ); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index ba565a58d6c8f9..66d1c62b766e91 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -27,7 +27,10 @@ import { useCommandPaletteDispatch, useCommandPaletteState, } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; -import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils'; +import { + getLocationHref, + isExternalLocation, +} from 'sentry/components/commandPalette/ui/locationUtils'; import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; @@ -36,7 +39,9 @@ import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; import {fzf} from 'sentry/utils/search/fzf'; import type {Theme} from 'sentry/utils/theme'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; +import {useNavigate} from 'sentry/utils/useNavigate'; const MotionButton = motion.create(Button); const MotionIconSearch = motion.create(IconSearch); @@ -66,15 +71,13 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { - onAction: ( - action: CollectionTreeNode, - options?: {modifierKeys?: {shiftKey: boolean}} - ) => void; children?: React.ReactNode; + closeModal?: () => void; } export function CommandPalette(props: CommandPaletteProps) { const theme = useTheme(); + const navigate = useNavigate(); const store = CMDKCollection.useStore(); const state = useCommandPaletteState(); @@ -205,6 +208,7 @@ export function CommandPalette(props: CommandPaletteProps) { disallowTypeAhead: true, }); + const {closeModal} = props; const onActionSelection = useCallback( ( key: string | number | null, @@ -224,7 +228,7 @@ 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, options); + action.onAction(); } dispatch({type: 'push action', key: action.key, label: action.display.label}); return; @@ -232,9 +236,21 @@ export function CommandPalette(props: CommandPaletteProps) { analytics.recordAction(action, resultIndex, ''); dispatch({type: 'trigger action'}); - props.onAction(action, options); + + if ('to' in action) { + const normalizedTo = normalizeUrl(action.to); + if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) { + window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer'); + } else { + navigate(normalizedTo); + } + } else if ('onAction' in action) { + action.onAction(); + } + + closeModal?.(); }, - [actions, analytics, dispatch, props] + [actions, analytics, closeModal, dispatch, navigate] ); const resultsListRef = useRef(null); diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 37e217320a5ad2..3eed303469c376 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -1,50 +1,14 @@ -import {useCallback} from 'react'; import {css} from '@emotion/react'; 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 {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; -import { - getLocationHref, - isExternalLocation, -} from 'sentry/components/commandPalette/ui/locationUtils'; import type {Theme} from 'sentry/utils/theme'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { - const navigate = useNavigate(); - - const handleSelect = useCallback( - ( - action: CollectionTreeNode, - options?: {modifierKeys?: {shiftKey: boolean}} - ) => { - if ('to' in action) { - const normalizedTo = normalizeUrl(action.to); - if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) { - window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer'); - } else { - navigate(normalizedTo); - } - } else if ('onAction' in action) { - action.onAction(); - // When the action has children, the palette will push into them so the - // user can select a secondary action — keep the modal open. - if (action.children.length > 0) { - return; - } - } - closeModal(); - }, - [navigate, closeModal] - ); - return ( - + diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 5f5b11de3840a9..c6ad3b6168cfff 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -34,34 +34,14 @@ Wrap your tree in `CommandPaletteProvider` and place the `CommandPalette` UI com ```tsx -import {useCallback} from 'react'; - import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import { - CMDKAction, CMDKAction, CommandPaletteProvider, } 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 {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useNavigate} from 'sentry/utils/useNavigate'; function YourComponent() { - const navigate = useNavigate(); - - const handleAction = useCallback( - (action: CollectionTreeNode) => { - if ('to' in action) { - navigate(normalizeUrl(String(action.to))); - } else if ('onAction' in action) { - action.onAction(); - } - }, - [navigate] - ); - return ( {/* Navigation action */} @@ -82,7 +62,7 @@ function YourComponent() { {/* The command palette UI — also accepts inline actions via children */} - + Date: Fri, 10 Apr 2026 06:37:45 +0200 Subject: [PATCH 42/43] ref(cmdk): Inline getLocationHref and isExternalLocation into commandPalette Both helpers were only used in commandPalette.tsx after the onAction removal. The separate module added indirection without reuse benefit, so inline the two functions and delete locationUtils.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../commandPalette/ui/commandPalette.tsx | 20 +++++++++++++++---- .../commandPalette/ui/locationUtils.ts | 19 ------------------ 2 files changed, 16 insertions(+), 23 deletions(-) delete mode 100644 static/app/components/commandPalette/ui/locationUtils.ts diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 66d1c62b766e91..7298ad558125e0 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -7,6 +7,7 @@ import {mergeProps} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; import {useTreeState} from '@react-stately/tree'; import {AnimatePresence, motion} from 'framer-motion'; +import type {LocationDescriptor} from 'history'; import errorIllustration from 'sentry-images/spot/computer-missing.svg'; @@ -27,16 +28,13 @@ import { useCommandPaletteDispatch, useCommandPaletteState, } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; -import { - getLocationHref, - isExternalLocation, -} from 'sentry/components/commandPalette/ui/locationUtils'; import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; import {fzf} from 'sentry/utils/search/fzf'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; @@ -60,6 +58,20 @@ function makeLeadingItemAnimation(theme: Theme) { }; } +function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + if (typeof resolved === 'string') { + return resolved; + } + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} + type CommandPaletteActionMenuItem = MenuListItemProps & { children: CommandPaletteActionMenuItem[]; key: string; diff --git a/static/app/components/commandPalette/ui/locationUtils.ts b/static/app/components/commandPalette/ui/locationUtils.ts deleted file mode 100644 index c67f339aed5da2..00000000000000 --- a/static/app/components/commandPalette/ui/locationUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {LocationDescriptor} from 'history'; - -import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; - -export function getLocationHref(to: LocationDescriptor): string { - const resolved = locationDescriptorToTo(to); - - if (typeof resolved === 'string') { - return resolved; - } - - return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; -} - -export function isExternalLocation(to: LocationDescriptor): boolean { - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(getLocationHref(to), currentUrl.href); - return targetUrl.origin !== currentUrl.origin; -} From f37012928f142614b4a3b4fea6b2de6020b13243 Mon Sep 17 00:00:00 2001 From: JonasBa Date: Mon, 13 Apr 2026 10:01:55 +0200 Subject: [PATCH 43/43] fix(cmdk): Move slot outlets outside CommandPalette to match master architecture Outlets are now rendered in navigation/index.tsx (via CommandPaletteSlotOutlets), not inside CommandPalette. CommandPalette no longer accepts children. Updated tests to render SlotOutlets separately and updated stories/docs to reflect the new structure. --- .../commandPalette/__stories__/components.tsx | 23 +++++----- .../commandPalette/ui/commandPalette.spec.tsx | 44 +++++++++++++++---- .../commandPalette/ui/commandPalette.tsx | 15 ------- .../commandPalette/ui/modal.spec.tsx | 31 ++++++++++--- .../components/commandPalette/ui/modal.tsx | 5 +-- .../useCommandPaletteActions.mdx | 27 ++++++------ 6 files changed, 87 insertions(+), 58 deletions(-) diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index c4cec99a9ba54f..069d99c71c5120 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -19,18 +19,17 @@ export function CommandPaletteDemo() { onAction={() => addSuccessMessage('Child action executed')} /> - - - addSuccessMessage('Select all')} - /> - addSuccessMessage('Deselect all')} - /> - - + + addSuccessMessage('Select all')} + /> + addSuccessMessage('Deselect all')} + /> + + ); } diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 0c420e49e0f0ed..b7192c10e0e429 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -42,6 +42,28 @@ function GlobalActionsComponent({children}: {children?: React.ReactNode}) { ); } +/** + * Renders the slot outlets that live outside CommandPalette in the real app + * (they are mounted in navigation/index.tsx). Tests that use + * must include this component so slot consumers + * have a registered outlet element to portal into. + */ +function SlotOutlets() { + return ( +
+ + {p =>
} + + + {p =>
} + + + {p =>
} + +
+ ); +} + const onChild = jest.fn(); function AllActions() { @@ -607,6 +629,7 @@ describe('CommandPalette', () => { + ); @@ -624,6 +647,7 @@ describe('CommandPalette', () => { + ); @@ -638,6 +662,7 @@ describe('CommandPalette', () => { + ); @@ -655,6 +680,7 @@ describe('CommandPalette', () => { + ); @@ -679,6 +705,7 @@ describe('CommandPalette', () => { + ); @@ -701,6 +728,7 @@ describe('CommandPalette', () => { + ); @@ -712,12 +740,10 @@ describe('CommandPalette', () => { expect(options[2]).toHaveAccessibleName('Global Action'); }); - it('actions passed as children to CommandPalette via global slot are not duplicated', async () => { - // This mirrors the real app setup in modal.tsx where GlobalCommandPaletteActions - // is passed as children to CommandPalette. Those actions use - // internally, which creates a circular portal: - // the consumer is rendered inside the global outlet div and then portals back to it. - // Registration must be idempotent so the slot→portal transition never yields duplicates. + it('actions registered via a slot consumer are not duplicated', async () => { + // GlobalCommandPaletteActions uses internally. + // The slot consumer portals children into the outlet element. Registration must be + // idempotent so the slot→portal transition never yields duplicates. function ActionsViaGlobalSlot() { return ( @@ -729,9 +755,9 @@ describe('CommandPalette', () => { render( - - - + + + ); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index cae63bbf9f4ec8..7d300f13177526 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -22,7 +22,6 @@ import {Text} from '@sentry/scraps/text'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import {CMDKCollection} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; -import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import { useCommandPaletteDispatch, useCommandPaletteState, @@ -72,7 +71,6 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { - children?: React.ReactNode; closeModal?: () => void; } @@ -414,19 +412,6 @@ export function CommandPalette(props: CommandPaletteProps) { - - {p =>
} - - - {p =>
} - - - {p => ( -
- {props.children} -
- )} -
{treeState.collection.size === 0 ? ( isEmptyPromptQuery ? null : ( diff --git a/static/app/components/commandPalette/ui/modal.spec.tsx b/static/app/components/commandPalette/ui/modal.spec.tsx index 965a766fcd21f8..eba0780ff9a6f9 100644 --- a/static/app/components/commandPalette/ui/modal.spec.tsx +++ b/static/app/components/commandPalette/ui/modal.spec.tsx @@ -18,11 +18,6 @@ jest.mock('@tanstack/react-virtual', () => ({ }, })); -// Avoid pulling in the full global actions tree (needs org context, feature flags, etc.) -jest.mock('sentry/components/commandPalette/ui/commandPaletteGlobalActions', () => ({ - GlobalCommandPaletteActions: () => null, -})); - import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {cmdkQueryOptions} from 'sentry/components/commandPalette/types'; @@ -53,6 +48,28 @@ function makeRenderProps(closeModal: jest.Mock) { }; } +/** + * Renders the slot outlets that live outside CommandPalette in the real app + * (they are mounted in navigation/index.tsx). Tests that use + * must include this component so slot consumers + * have a registered outlet element to portal into. + */ +function SlotOutlets() { + return ( +
+ + {p =>
} + + + {p =>
} + + + {p =>
} + +
+ ); +} + describe('CommandPaletteModal', () => { beforeEach(() => { jest.resetAllMocks(); @@ -71,6 +88,7 @@ describe('CommandPaletteModal', () => { + ); @@ -132,6 +150,7 @@ describe('CommandPaletteModal', () => { + ); @@ -154,6 +173,7 @@ describe('CommandPaletteModal', () => { + ); @@ -178,6 +198,7 @@ describe('CommandPaletteModal', () => { + ); diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 3eed303469c376..81479e8a8e9794 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -2,15 +2,12 @@ import {css} from '@emotion/react'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; -import {GlobalCommandPaletteActions} from 'sentry/components/commandPalette/ui/commandPaletteGlobalActions'; import type {Theme} from 'sentry/utils/theme'; export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps) { return ( - - - + ); } diff --git a/static/app/components/commandPalette/useCommandPaletteActions.mdx b/static/app/components/commandPalette/useCommandPaletteActions.mdx index 0ba55b1e7d6082..1b2737abc16f23 100644 --- a/static/app/components/commandPalette/useCommandPaletteActions.mdx +++ b/static/app/components/commandPalette/useCommandPaletteActions.mdx @@ -61,19 +61,20 @@ function YourComponent() { /> - {/* The command palette UI — also accepts inline actions via children */} - - - addSuccessMessage('Select all')} - /> - addSuccessMessage('Deselect all')} - /> - - + {/* Inline actions — register them directly inside the provider */} + + addSuccessMessage('Select all')} + /> + addSuccessMessage('Deselect all')} + /> + + + {/* The command palette UI */} + ); }