This app now has a global command palette opened with Cmd + K on macOS and Ctrl + K elsewhere.
It is intentionally built as a small plugin system:
components/search/search-provider.tsxOwns global state, keyboard shortcut handling, submenu navigation, confirmation flow, and action execution.components/search/search-palette.tsxPure-ish UI for rendering and navigating actions.components/search/search-types.tsThe contract for plugins and actions.components/search/plugins/*The default action sources. Add new behavior here instead of bloating the provider.components/search/search-launcher.tsxReusable visible trigger button used by the shell.
The palette is a tree of SearchAction objects.
- An action with
performis executable. - An action with
childrenis a submenu. - An action with
confirmautomatically gets a built-in confirmation dialog beforeperformruns.
The provider rebuilds the action tree on every render from the active plugin list plus the current runtime context. Because of that, actions can safely depend on live app state like:
- current query text
- current route
- current organization
- available organizations
- current theme
- sidebar state
- server-provided docs tree
Main types live in components/search/search-types.ts.
export interface SearchAction {
id: string
title: string
description?: string
section?: string
keywords?: string[]
icon?: IconSvgElement
badge?: string
shortcut?: string
disabled?: boolean
keepOpen?: boolean
confirm?: {
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: "default" | "destructive"
}
view?: {
inputPlaceholder?: string
emptyTitle?: string
emptyDescription?: string
queryOnOpen?: "preserve" | "clear" | string
}
children?: SearchAction[]
perform?: (helpers: { close: () => void }) => void | Promise<void>
}
export interface SearchPlugin {
id: string
getActions: (context: SearchRuntimeContext) => SearchAction[]
}Each plugin receives SearchRuntimeContext, which currently exposes:
- user input:
query - routing helpers:
navigate,openUrl - settings helpers:
openSettings,openScopedSettings - workspace helpers:
toggleSidebar,setTheme,signOut - organization state and switching:
organizations,currentOrganizationId,switchOrganization - docs data:
docsNodes - permission access:
hasPermission
If a future plugin needs more app state, add it to SearchRuntimeContext in one place and then thread it through search-provider.tsx.
- Create a new file in
components/search/plugins/. - Export a
SearchPlugin. - Add it to
defaultSearchPluginsincomponents/search/plugins/index.ts.
Example:
import { Rocket01Icon } from "@hugeicons/core-free-icons"
import type { SearchPlugin } from "../search-types"
import { defineSearchPlugin } from "../search-helpers"
export const billingPlugin: SearchPlugin = defineSearchPlugin({
id: "billing",
getActions: (context) => [
{
id: "billing:overview",
title: "Billing Overview",
description: "Open billing settings",
section: "Billing",
icon: Rocket01Icon,
keywords: ["billing", "payments", "stripe"],
perform: ({ close }) => {
context.navigate("/billing")
close()
},
},
],
})If you want a plugin that reacts to what the user typed, read context.query. The built-in weather example plugin does exactly that.
if (context.query.trim().length >= 2) {
return [
{
id: `weather:${context.query}`,
title: `Weather in "${context.query}"`,
perform: async ({ close }) => {
// fetch data for the typed city
close()
},
},
]
}If you want a plugin to return direct global-search results instead of opening a submenu, just emit normal actions from getActions(context) based on async state already exposed on the runtime context. The built-in names API example works this way.
if (context.query.trim().length >= 2) {
return context.nameSearch.items.map((name) => ({
id: `name:${name}`,
title: name,
section: "API Examples",
perform: ({ close }) => {
close()
},
}))
}If you want an action to open a tool-like submenu and take over the input field, add children plus view.
{
id: "weather:check",
title: "Check weather",
view: {
inputPlaceholder: "Enter a city name...",
emptyTitle: "Type a city name",
emptyDescription: "This view uses the top input as tool input.",
queryOnOpen: "clear",
},
children: [
// submenu actions built from context.query
],
}Submenus are just nested children.
{
id: "reports",
title: "Reports",
children: [
{
id: "reports:weekly",
title: "Weekly Report",
perform: ({ close }) => {
context.navigate("/reports/weekly")
close()
},
},
],
}The provider stores submenu path by action id, so nested menus stay stable without hard-coding routes in the UI layer.
Add confirm plus perform.
{
id: "danger:reset-demo",
title: "Reset Demo Data",
confirm: {
title: "Reset demo data?",
description: "This will remove the current demo records.",
confirmLabel: "Reset",
cancelLabel: "Cancel",
variant: "destructive",
},
perform: async ({ close }) => {
await fetch("/api/demo/reset", { method: "POST" })
close()
},
}The palette will show the shared alert dialog automatically.
Filtering is local to the current menu.
titlematches rank highestkeywordshelp synonymsdescriptionis used as a lower-priority fallbacksectioncontrols group headings in the result list
If you want an action to be easy to find, spend most effort on title and keywords.
If you generate query-specific actions, include the typed query in keywords and usually in the title too.
The documentation submenu is built from the MDX sidebar tree.
- Each dashboard layout loads
getMdxSidebarTree("/docs") - The tree is passed into
Shell Shellpasses it intoSearchProvider- the navigation plugin turns that tree into nested actions
If docs search ever needs richer metadata like descriptions or tags, extend the docs tree shape at the server boundary first, then map that data inside navigation-plugin.ts.
The palette should continue to feel like the rest of the app:
- use Hugeicons for every action icon
- keep chrome soft and layered like the shell/settings modal
- prefer grouped actions over one giant flat list
- prefer plugin modules over giant conditionals in
search-provider.tsx
- Add one plugin per domain area.
- Keep actions declarative.
- Use
keywordsfor discoverability instead of clever titles. - Use
keepOpenonly for actions like toggles where staying inside the palette is useful. - Prefer
context.navigate(...)over manual URL mutation.
- Do not hard-code new actions directly inside
search-palette.tsx. - Do not put business logic in the renderer if it can live in a plugin.
- Do not make plugins fetch asynchronously inside
getActions. If async data is needed, load it in a hook/provider first and expose it throughSearchRuntimeContext.