{
)}
{page !== "root" && ESC}
- {/* Announce sub-page transitions to screen readers — the placeholder
+ {/* Announce picker-page transitions to screen readers — the placeholder
text changes but isn't reliably announced on its own. Strip the
trailing ellipsis so the announcement isn't verbalized as
"dot dot dot" by some screen readers. */}
- {page === "root" ? "" : subPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
+ {page === "root"
+ ? ""
+ : pickerPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
- {/* Sub-pages render their own contextual empty state, so only show
+ {/* Picker pages render their own contextual empty state, so only show
cmdk's generic Empty on the root page. */}
{page === "root" && (
diff --git a/frontend/components/CommandPalette/groups/commands.ts b/frontend/components/CommandPalette/groups/commands.ts
index 94b41a3f72..5f6840f34e 100644
--- a/frontend/components/CommandPalette/groups/commands.ts
+++ b/frontend/components/CommandPalette/groups/commands.ts
@@ -49,7 +49,7 @@ const buildCommandsItems = (
]
: []),
- // View commands — open sub-pages with searchable lists. Placed at
+ // View commands — open picker pages with searchable lists. Placed at
// the top of the Commands group so view actions appear before write
// actions like Add hosts within this group.
{
@@ -68,7 +68,7 @@ const buildCommandsItems = (
"search hosts",
],
onAction: onViewHost,
- opensSubPage: true,
+ opensPickerPage: true,
},
{
id: "view-software",
@@ -88,7 +88,7 @@ const buildCommandsItems = (
"inventory",
],
onAction: onViewSoftware,
- opensSubPage: true,
+ opensPickerPage: true,
},
// View software library — Premium-only and hidden on "All fleets" since
// libraries are per-fleet.
@@ -114,7 +114,7 @@ const buildCommandsItems = (
"search library",
],
onAction: onViewSoftwareLibrary,
- opensSubPage: true,
+ opensPickerPage: true,
},
]
: []),
@@ -134,7 +134,7 @@ const buildCommandsItems = (
"search reports",
],
onAction: onViewReport,
- opensSubPage: true,
+ opensPickerPage: true,
},
{
id: "view-policy",
@@ -151,7 +151,7 @@ const buildCommandsItems = (
"search policies",
],
onAction: onViewPolicy,
- opensSubPage: true,
+ opensPickerPage: true,
},
// Actions — users who can write
diff --git a/frontend/components/CommandPalette/groups/controls.ts b/frontend/components/CommandPalette/groups/controls.ts
index e073586861..af09fa5ab8 100644
--- a/frontend/components/CommandPalette/groups/controls.ts
+++ b/frontend/components/CommandPalette/groups/controls.ts
@@ -32,7 +32,7 @@ const buildControlsItems = (
"patch",
],
},
- // OS settings sub-pages
+ // OS settings sub-routes
{
id: "controls-os-settings",
label: "OS settings",
@@ -101,7 +101,7 @@ const buildControlsItems = (
: []),
],
},
- // Setup experience sub-pages — Premium-only.
+ // Setup experience sub-routes — Premium-only.
...(isPremiumTier
? [
{
diff --git a/frontend/components/CommandPalette/helpers.ts b/frontend/components/CommandPalette/helpers.ts
index f3fcdd2204..c3cdb37414 100644
--- a/frontend/components/CommandPalette/helpers.ts
+++ b/frontend/components/CommandPalette/helpers.ts
@@ -21,7 +21,7 @@ export interface ICommandSubItem {
export interface ICommandItem {
id: string;
label: string;
- group: string;
+ group: typeof GROUPS[number];
path?: string;
keywords?: string[];
/** Displayed on the right when navigating would switch your team context */
@@ -30,8 +30,8 @@ export interface ICommandItem {
subItems?: ICommandSubItem[];
/** Custom action instead of navigation */
onAction?: () => void;
- /** True when selecting this item opens a sub-page (not a navigation). */
- opensSubPage?: boolean;
+ /** True when selecting this item opens a picker page (not a navigation). */
+ opensPickerPage?: boolean;
}
export interface ICommandPaletteContext {
diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md
index ed08ffab76..98bd03efdd 100644
--- a/frontend/docs/patterns.md
+++ b/frontend/docs/patterns.md
@@ -17,6 +17,7 @@ should be discussed within the team and documented before merged.
- [React Context](#react-context)
- [Fleet API calls](#fleet-api-calls)
- [Page routing](#page-routing)
+- [Command palette](#command-palette)
- [Styles](#styles)
- [Icons and images](#icons-and-images)
- [Testing](#testing)
@@ -659,6 +660,174 @@ const PageOrComponent = ({
};
```
+## Command palette
+
+The command palette is the keyboard surface for navigation and global actions. Power users discover features through it, so a missing entry is easy for them to overlook — but only the right kinds of features belong here.
+
+Source: `frontend/components/CommandPalette/`. Items are defined per group under `groups/`.
+
+### What belongs (and what doesn't)
+
+**Belongs in the palette:**
+
+- Navigation to any destination in the app — either its own top-level palette entry or nested under a parent entry via `subItems`
+- Global create actions where no entity is pre-selected ("Add report" opens a blank form)
+- Singleton config actions where the entity is implicit ("Edit Apple MDM" — there's only one Apple MDM config)
+- Picker actions that open an in-palette search for the user to choose an entity (e.g., "View host")
+
+**Doesn't belong:**
+
+- Per-entity edit / delete operations (editing a specific label, deleting a specific host) — those live on the entity's row or detail page where the entity is already in scope
+- Bulk-select operations that depend on an existing selection on a page
+- One-off UI affordances (toggles, expanders) tied to a specific view
+
+The dividing line: if the action requires the user to first pick a specific row, it stays on that row. If the action is global, a singleton, or starts a picker, it goes in the palette.
+
+### When to add a palette entry
+
+| Adding... | Goes in |
+|---|---|
+| A new top-level page (routed under a top nav item) | `groups/pages.ts` |
+| A new global create action (modal / form / blank create page) | `groups/commands.ts` |
+| A new picker action (like "View host") | `groups/commands.ts` with `opensPickerPage: true`, plus a picker in `frontend/components/CommandPalette/components/` |
+| A new MDM platform or connector (turn-on / singleton-edit) | `groups/mdm.ts` |
+| A new automation hook | `groups/automations.ts` |
+| A new settings page or admin route | `groups/settings.ts` |
+| A new control / policy / script feature | `groups/controls.ts` |
+| A new software action or view | `groups/software.ts` |
+
+Nested destinations under an existing palette entry live in that entry's `subItems` array, not as top-level entries. The user reaches the sub-item by expanding the parent (chevron) or when their search promotes the sub-item into Best match.
+
+These three "sub-" terms each mean exactly one thing in this codebase — keep them distinct:
+
+- **Sub-item** — an `ICommandSubItem` in a parent palette entry's `subItems` array
+- **Picker page** — the secondary screen opened when an entry has `opensPickerPage: true` (View host, View report, Switch fleet)
+- **Sub-route** — an app route nested under another (e.g., `/settings/integrations` under `/settings`)
+
+### Required and optional fields
+
+```ts
+interface ICommandItem {
+ id: string; // unique kebab-case
+ label: string; // sentence case, verb first ("Add report")
+ group: typeof GROUPS[number]; // one of `GROUPS` in helpers.ts
+ path?: string; // navigation target (use withTeamId() if team-scoped)
+ onAction?: () => void; // alternative to path for custom side effects
+ keywords?: string[]; // synonyms + aliases — see below
+ teamName?: string; // chip shown when the action switches the user's fleet context
+ subItems?: ICommandSubItem[];
+ opensPickerPage?: boolean; // shows the chevron-right; required for picker actions
+}
+```
+
+### Label conventions
+
+- Sentence case: "Add report", not "Add Report".
+- Verb first for actions: "Add", "Edit", "Delete", "Run", "View", "Manage", "Turn on" / "Turn off".
+- No trailing punctuation.
+- Match the destination page's own primary-button text where possible.
+- Use **fleet** / **report** (current product terminology), not **team** / **query**. Existing items haven't been mass-renamed; this applies to *new* items only.
+
+### Keyword authoring
+
+Best match scoring is **label-first by tier.** `scoreMatch()` in `helpers.ts` ranks a single text (label or keyword) against the query and returns one of these tier values:
+
+| Tier | Label score | Keyword score |
+|---|---|---|
+| exact | 100 | 50 |
+| prefix | 90 | 40 |
+| word-prefix | 80 | 30 |
+| substring | 70 | — (label-only) |
+
+Any label hit outranks any keyword hit — even the weakest label tier (substring, 70) beats the strongest keyword tier (exact, 50). That's what shapes how keywords should be written: they only matter when the query doesn't hit the label at all. Duplicating label words in keywords just adds a redundant, lower-scoring path.
+
+A few additional behaviors worth knowing — see `computeBestMatch()` and `scoreItemForBestMatch()` in `helpers.ts` for the full mechanics:
+
+- **Noise floor.** 2-character queries only consider label-exact + label-prefix (no word-prefix, no substring, no keywords). 3+ characters unlocks the full ladder. See `BEST_MATCH_MIN_QUERY` / `BEST_MATCH_FULL_LADDER_MIN`.
+- **Multi-token search.** A query like "settings org" is also scored as two tokens; each must find a positive match (against label or keywords), and the item takes the *minimum per-token score*. This lets order-independent searches like "settings org" → "Organization settings" promote without a phrase match.
+- **Word splits.** Word boundaries split on whitespace AND hyphens, so "API-only user" yields `["api", "only", "user"]` — a query for `only` word-prefix-matches.
+- **Substring is label-only.** Keyword substrings don't score (too noisy with short tokens); keywords cap at word-prefix.
+
+**Do:**
+
+- Add single distinct words a user might type that aren't already in the label
+- Add the standard verb synonyms for every action label:
+ - `add` → `create`, `new`
+ - `edit` → `update`, `change`, `modify`
+ - `delete` → `remove`
+ - `view` → `open`, `show`
+ - `run` → `execute`
+ - `turn on` → `activate`, `set up`, `configure`
+- Add acronyms and alternate names users actually type: `idp`, `ca`, `cve`, `fma`, `abm`, `vpp`, `mdm`, `dep`, `ade`
+- Add platform aliases where relevant:
+ - Apple → `iphone`, `ipad`, `macbook`
+ - Windows → `pc`, `win10`, `win11`
+ - Android → `phone`, `tablet`
+- Include legacy product terms during rename windows (e.g., `queries`, `query` on Reports until the term fully drains)
+
+**Don't:**
+
+- Repeat words from the label. `Add user` already scores "add" or "user" via the label tiers (exact / prefix / word-prefix / substring, 70–100). Adding them as keywords would only score lower (30–50), never changing the ranking.
+- Use multi-word keyword phrases when a single word works. A keyword like `create` matches as keyword-exact / -prefix / -word-prefix at the token level. A multi-word keyword like `create user` only matches when the whole phrase appears as one token in the query — multi-token splitting won't reach into it.
+- Pile in low-signal substrings ("the", "some", generic verbs).
+
+### Permission gating
+
+Mirror the destination page's gate exactly. If the page rejects technicians, gate the palette item on `!isTechnician`. If the destination renders `` on free tier, gate the palette item on `isPremiumTier`. The palette must not route users to a screen they can't use.
+
+Reuse existing flags from `ICommandPaletteContext` (`frontend/components/CommandPalette/helpers.ts`) — that interface is the source of truth for the full list. The flags fall into a few buckets:
+
+- **Role-based write gates:** `canWrite`, `canAccessSettings`, `canAccessControls`, `canRunLiveReport`, `canAddSoftware`, `canEditCustomVariable`, `canManagePolicyAutomations`, `canManageSoftwareAutomations`, `canManageReportAutomations`, `isTechnician`
+- **Tier / mode:** `isPremiumTier`, `isPrimoMode`, `isDarkMode`
+- **Feature configured:** `isMacMdmEnabledAndConfigured`, `isWindowsMdmEnabledAndConfigured`, `isAndroidMdmEnabledAndConfigured`, `isVppEnabled`
+- **Context shape:** `hasTeamSelected`, `currentTeam`, `availableTeams`, `config`, `search`
+
+Add a new flag to `ICommandPaletteContext` + `CommandPalette.tsx` only when no existing one models the destination's check. When you add one, mirror the destination page's predicate exactly — several existing flags (`canManageReportAutomations`, `canEditCustomVariable`, `canAddSoftware`) document the narrower role checks they encode; follow that pattern.
+
+### Team context (`teamName`)
+
+Set `teamName` when invoking the action will switch the user's current fleet context. The palette renders it as a chip on the right so the user sees the upcoming switch before they click.
+
+Each group builder receives an `IDerivedContext` (computed once by `deriveContext()` in `groups/derivations.ts`) as its second argument. Destructure the chip helper you need from it rather than hardcoding fleet names:
+
+```ts
+const buildExampleItems = (ctx, derived) => {
+ const { switchesFromUnassigned, switchesFromAllFleets } = derived;
+ // ...
+};
+```
+
+The three chip helpers:
+
+- `switchesFromUnassigned` — destination requires a specific fleet, action invokable from Unassigned
+- `switchesFromAllFleets` — destination requires a specific fleet, action invokable from All fleets
+- `defaultDestination` — destination always lands on the default (e.g., "All fleets")
+
+Each returns `undefined` when no switch will actually happen, so you can pass it straight to `teamName` without guarding.
+
+### Search-only items
+
+Some entries are gated on the search string itself (e.g., the "Packs" page only appears when searching for `packs`). Use the `search` field from `ICommandPaletteContext` and a regex test:
+
+```ts
+.../packs|create new pack/.test(search.toLowerCase())
+ ? [/* the item */]
+ : []
+```
+
+Use this pattern sparingly — it bypasses the normal Best match ranking and should be reserved for legacy / deprecated features users only reach by name.
+
+### Tests
+
+Extend `frontend/components/CommandPalette/helpers.tests.ts` when adding a meaningful item:
+
+- New page / command: assert it appears for the right roles, hides for the wrong ones
+- Premium-only: assert it's absent in the `Fleet Free (isPremiumTier: false)` describe block
+- Primo mode hidden: add to the `Primo Mode (isPrimoMode: true)` block
+- New `teamName` chip: assert it renders / doesn't render against the relevant fleet contexts
+
+The scoring helpers (`scoreMatch`, `scoreItemForBestMatch`, `computeBestMatch`, `highlightMatches`) and tier constants (`SCORE_LABEL_*`, `SCORE_KEYWORD_*`) have their own describe blocks in `helpers.tests.ts` — you don't need to re-test the framework when adding an item. If your new item exposes a specific ranking case worth pinning (e.g., a multi-token query that should promote it over a similarly-named item), add a small `computeBestMatch` test alongside.
+
## Styles
Below are a few need-to-knows about what's available in Fleet's CSS: