FE: Add claude rules and FE patterns around command palette (#46826)

This commit is contained in:
RachelElysia
2026-06-10 10:56:49 -04:00
committed by GitHub
parent a78a12250e
commit 6dc756fffe
8 changed files with 248 additions and 30 deletions
+3
View File
@@ -115,6 +115,9 @@ Editing inside already-gated code (adding a field to a premium-only form, fixing
- "Teams" are now called "fleets" in the product. Code still uses `team_id`, `useTeamIdParam`, `permissions.isTeamAdmin`, etc. — don't rename existing APIs, but use "fleet" in new user-facing strings and comments.
- "Queries" are now called "reports." The word "query" now refers solely to a SQL query. Code still uses `useQuery`, `queryKey`, etc. for React Query — that's unrelated to the product terminology change.
## Command palette
If you edit `frontend/router/paths.ts` or `frontend/router/index.tsx`, add a new MDM connector / singleton config, add a new global create / automation / settings action, or add a new picker action, load the `command-palette` skill before finishing — these changes almost always need a matching entry under `frontend/components/CommandPalette/groups/`. The palette is for navigation and global actions — not per-entity (row-level) operations, bulk-select actions, or per-view UI toggles.
## Linting & Formatting
- ESLint: extends airbnb + typescript-eslint + prettier
- Prettier: default config (`.prettierrc.json`)
+42
View File
@@ -0,0 +1,42 @@
---
name: command-palette
description: Authoring guide for the Fleet command palette. Use when adding or editing items in frontend/components/CommandPalette/groups/, when editing frontend/router/paths.ts or frontend/router/index.tsx, or when adding a new top-level page, global create action, MDM connector / singleton config, automation hook, or picker action that needs a palette entry.
allowed-tools: Read, Grep, Glob, Bash(yarn test*)
effort: medium
---
# Command palette authoring
The canonical guide lives in **`frontend/docs/patterns.md` § Command palette** — read that first. It covers what belongs (and what doesn't), the group-to-file mapping, required/optional fields, label conventions, the full keyword/synonym checklist, permission gating, `teamName` chips, search-only items, and test expectations.
This skill exists to make sure that guide gets followed when palette-worthy changes land.
## Before adding an item
1. **Read `frontend/docs/patterns.md` § Command palette** end to end.
2. **Grep the target group file** for similar existing items. Match their shape — field order, keyword style, gating, `teamName` helper usage — instead of inventing a new pattern. The groups are the source of truth for current conventions:
```
frontend/components/CommandPalette/groups/
```
3. **Confirm the destination page's own permission check**, then mirror it on the palette item using a flag from `ICommandPaletteContext` (`frontend/components/CommandPalette/helpers.ts`). Add a new flag there only if no existing one models the destination's check. Don't route users to a screen they can't use.
## After adding an item
1. **Update `frontend/components/CommandPalette/helpers.tests.ts`**:
- Assert the item appears for the right roles and hides for the wrong ones
- If premium-only: assert absence in the `Fleet Free (isPremiumTier: false)` block
- If hidden in primo mode: add to the `Primo Mode (isPrimoMode: true)` block
- If it sets a `teamName` chip: assert it renders / doesn't render against the relevant fleet contexts
2. Run the palette tests:
```
yarn test frontend/components/CommandPalette/helpers.tests.ts
```
(Note: test files use `.tests.ts` plural, and `yarn test` — not `yarn jest` — uses the project's jest config.)
## When *not* to add an entry
- Per-entity edit / delete operations (the entity is already in scope on its row or detail page)
- Bulk-select operations that depend on an existing selection
- One-off UI affordances (toggles, expanders) tied to a single view
See patterns.md for the dividing line and the full "doesn't belong" list.
@@ -247,7 +247,7 @@ describe("CommandPalette", () => {
});
describe("Keyboard shortcuts", () => {
it("opens the switch-fleet sub-page on Cmd+Shift+F", async () => {
it("opens the switch-fleet picker page on Cmd+Shift+F", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
@@ -303,11 +303,11 @@ describe("CommandPalette", () => {
).not.toBeInTheDocument();
});
it("Escape returns to root from a sub-page instead of closing", async () => {
it("Escape returns to root from a picker page instead of closing", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Navigate into the switch-fleet sub-page
// Navigate into the switch-fleet picker page
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
@@ -324,12 +324,12 @@ describe("CommandPalette", () => {
});
});
it("Escape returns to root from a picker sub-page (view-host)", async () => {
it("Escape returns to root from a picker page (view-host)", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// The root page lists commands; find "View host" and activate it
// to reach the view-host sub-page.
// to reach the view-host picker page.
const viewHost = await screen.findByText("View host");
await user.click(viewHost);
@@ -360,7 +360,7 @@ describe("CommandPalette", () => {
});
});
it("Backspace on empty input goes back from a sub-page", async () => {
it("Backspace on empty input goes back from a picker page", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
@@ -222,7 +222,7 @@ const CommandPalette = (): JSX.Element | null => {
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const subPagePlaceholders: Partial<Record<Page, string>> = {
const pickerPagePlaceholders: Partial<Record<Page, string>> = {
"switch-fleet": "Search a fleet...",
"view-host": "Search hosts...",
"view-software": "Search software inventory...",
@@ -230,7 +230,7 @@ const CommandPalette = (): JSX.Element | null => {
"view-report": "Search reports...",
"view-policy": "Search policies...",
};
const subPagePlaceholder = subPagePlaceholders[page];
const pickerPagePlaceholder = pickerPagePlaceholders[page];
// Toggle open on Cmd+K / Ctrl+K; jump to switch-fleet on Cmd+Shift+F.
// Focus is handled by the [open, page] effect below — don't rAF here, the
@@ -286,7 +286,7 @@ const CommandPalette = (): JSX.Element | null => {
}
}, [open, page]);
// Backspace on empty input returns to root from a sub-page.
// Backspace on empty input returns to root from a picker page.
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (page !== "root" && e.key === "Backspace" && !search) {
@@ -297,13 +297,13 @@ const CommandPalette = (): JSX.Element | null => {
[page, search, goBack]
);
// Intercept Escape on a sub-page so it returns to root instead of
// Intercept Escape on a picker page so it returns to root instead of
// closing the dialog. cmdk 1.1.1's Command.Dialog doesn't forward
// `onEscapeKeyDown` to Radix's Dialog.Content, so we can't override
// the close intent via props.
//
// Approach: a capture-phase document listener that calls
// `stopImmediatePropagation` on Escape from a sub-page. This prevents
// `stopImmediatePropagation` on Escape from a picker page. This prevents
// both Radix's DismissableLayer ESC handler AND any sibling listeners
// from firing on this event — the dialog never learns about the press,
// so it doesn't close. `useLayoutEffect` is intentional: it attaches
@@ -535,7 +535,7 @@ const CommandPalette = (): JSX.Element | null => {
/>
</button>
)}
{item.opensSubPage && (
{item.opensPickerPage && (
<span aria-hidden className={`${baseClass}__item-more`}>
<Icon
name="chevron-right"
@@ -615,11 +615,11 @@ const CommandPalette = (): JSX.Element | null => {
<span className={`${baseClass}__item-label`}>
<HighlightedLabel text={target.label} query={search} />
</span>
{/* Render the sub-page chevron for items that open a
picker (View host, View software, etc.). The
{/* Render the picker-page chevron for items that open
a picker (View host, View software, etc.). The
chevron only belongs to parent items sub-items
navigate directly. */}
{!sub && item.opensSubPage && (
{!sub && item.opensPickerPage && (
<span aria-hidden className={`${baseClass}__item-more`}>
<Icon
name="chevron-right"
@@ -778,7 +778,9 @@ const CommandPalette = (): JSX.Element | null => {
<Command.Input
ref={inputRef}
className={`${baseClass}__input`}
placeholder={subPagePlaceholder ?? "Search for a page or command..."}
placeholder={
pickerPagePlaceholder ?? "Search for a page or command..."
}
value={search}
onValueChange={setSearch}
onKeyDown={onKeyDown}
@@ -818,15 +820,17 @@ const CommandPalette = (): JSX.Element | null => {
)}
{page !== "root" && <kbd className={`${baseClass}__esc-hint`}>ESC</kbd>}
</div>
{/* 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. */}
<div role="status" aria-live="polite" className="sr-only">
{page === "root" ? "" : subPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
{page === "root"
? ""
: pickerPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
</div>
<Command.List className={`${baseClass}__list`}>
{/* 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" && (
<Command.Empty className={`${baseClass}__empty`}>
@@ -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
@@ -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
? [
{
@@ -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 {
+169
View File
@@ -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, 70100). Adding them as keywords would only score lower (3050), 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 `<PremiumFeatureMessage />` 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: