diff --git a/packages/app/src/components/header/header.tsx b/packages/app/src/components/header/header.tsx
index 4b8608df6..728ac21e6 100644
--- a/packages/app/src/components/header/header.tsx
+++ b/packages/app/src/components/header/header.tsx
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { track } from '@/lib/analytics';
+import { CommandPalette } from '@/components/ui/command-palette';
import { ModeToggle } from '@/components/ui/mode-toggle';
import { NewBadge } from '@/components/ui/new-badge';
import { MinecraftToggles } from '@/components/minecraft/minecraft-toggles';
@@ -253,6 +254,11 @@ export const Header = ({ starCount }: { starCount?: number | null }) => {
{/* Right side */}
+ {/* Hidden on ultra-narrow (<360px) viewports — the 320px header is
+ already at capacity (see the 320x700 component test). */}
+
+
+
diff --git a/packages/app/src/components/tab-nav.tsx b/packages/app/src/components/tab-nav.tsx
index e6464e206..2238740e0 100644
--- a/packages/app/src/components/tab-nav.tsx
+++ b/packages/app/src/components/tab-nav.tsx
@@ -15,6 +15,7 @@ import {
type DashboardRouteKey,
} from '@/lib/dashboard-routes';
import { localePath } from '@/lib/i18n';
+import { TAB_LABELS_EN } from '@/lib/tab-meta';
import { TAB_LABELS_ZH } from '@/lib/tab-meta-zh';
import { useFeatureGate } from '@/lib/use-feature-gate';
import { Card } from '@/components/ui/card';
@@ -33,22 +34,6 @@ import {
import { useClientSearchParams } from '@/hooks/useClientSearch';
import { cn } from '@/lib/utils';
-const TAB_LABELS_EN: Record
= {
- inference: 'Inference Performance',
- evaluation: 'Accuracy Evals',
- historical: 'Historical Trends',
- calculator: 'TCO Calculator',
- fleet: 'Fleet Lifecycle',
- reliability: 'Reliability',
- 'gpu-specs': 'Chip Specs',
- submissions: 'Submissions',
- collectivex: 'CollectiveX',
- 'ai-chart': 'AI Chart',
- 'gpu-metrics': 'PowerX',
- 'current-inferencex-image': 'Images',
- feedback: 'Feedback',
-};
-
const PRIMARY_TABS = DASHBOARD_ROUTES.filter((route) => route.navGroup === 'primary');
const GATED_TABS = DASHBOARD_ROUTES.filter((route) => route.navGroup === 'feature-gated');
diff --git a/packages/app/src/components/ui/command-palette.test.tsx b/packages/app/src/components/ui/command-palette.test.tsx
new file mode 100644
index 000000000..52962d5c0
--- /dev/null
+++ b/packages/app/src/components/ui/command-palette.test.tsx
@@ -0,0 +1,183 @@
+// @vitest-environment jsdom
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const push = vi.fn();
+let mockPathname = '/';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push }),
+ usePathname: () => mockPathname,
+}));
+
+vi.mock('next-themes', () => ({
+ useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }),
+}));
+
+import { CommandPalette } from '@/components/ui/command-palette';
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ mockPathname = '/';
+ push.mockClear();
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render() {
+ act(() => {
+ root.render(React.createElement(CommandPalette));
+ });
+}
+
+function openViaTrigger() {
+ const trigger = container.querySelector(
+ '[data-testid="command-palette-trigger"]',
+ ) as HTMLButtonElement;
+ act(() => trigger.click());
+}
+
+// React controlled inputs ignore direct `.value` assignment; use the native
+// setter so React sees the change (same pattern as searchable-select.test.ts).
+function setQuery(value: string) {
+ const input = document.body.querySelector(
+ '[data-testid="command-palette-input"]',
+ ) as HTMLInputElement;
+ const nativeSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )!.set!;
+ act(() => {
+ nativeSetter.call(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
+function pressOnInput(key: string) {
+ const input = document.body.querySelector(
+ '[data-testid="command-palette-input"]',
+ ) as HTMLInputElement;
+ act(() => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+ });
+}
+
+function optionLabels(): string[] {
+ return [...document.body.querySelectorAll('[role="option"]')].map((el) => el.textContent ?? '');
+}
+
+describe('CommandPalette', () => {
+ it('opens from the trigger button and lists grouped destinations', () => {
+ render();
+ expect(document.body.querySelector('[data-testid="command-palette"]')).toBeNull();
+ openViaTrigger();
+ expect(document.body.querySelector('[data-testid="command-palette"]')).not.toBeNull();
+ const labels = optionLabels();
+ expect(labels.some((label) => label.includes('Home'))).toBe(true);
+ expect(labels.some((label) => label.includes('NVIDIA B300'))).toBe(true);
+ });
+
+ it('opens on Ctrl+K', () => {
+ render();
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
+ });
+ expect(document.body.querySelector('[data-testid="command-palette"]')).not.toBeNull();
+ });
+
+ it('filters with punctuation-insensitive token matching and navigates on Enter', () => {
+ render();
+ openViaTrigger();
+ setQuery('kimi k3');
+ const labels = optionLabels();
+ expect(labels.some((label) => label.includes('Kimi K3'))).toBe(true);
+ pressOnInput('Enter');
+ expect(push).toHaveBeenCalledWith('/inference/kimi-k3');
+ // Palette closes after selection.
+ expect(document.body.querySelector('[data-testid="command-palette"]')).toBeNull();
+ });
+
+ it('supports arrow-key selection', () => {
+ render();
+ openViaTrigger();
+ setQuery('chip specs');
+ pressOnInput('ArrowDown');
+ const active = document.body.querySelector('[role="option"][aria-selected="true"]');
+ expect(active).not.toBeNull();
+ });
+
+ it('ignores Enter while an IME composition is being confirmed', () => {
+ render();
+ openViaTrigger();
+ setQuery('kimi k3');
+ const input = document.body.querySelector(
+ '[data-testid="command-palette-input"]',
+ ) as HTMLInputElement;
+ act(() => {
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }),
+ );
+ });
+ expect(push).not.toHaveBeenCalled();
+ // The palette stays open, still showing the query.
+ expect(document.body.querySelector('[data-testid="command-palette"]')).not.toBeNull();
+ });
+
+ it('clears the query when closed via the keyboard shortcut', () => {
+ render();
+ openViaTrigger();
+ setQuery('kimi');
+ // Close and reopen via Ctrl+K — the old filter must not persist.
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
+ });
+ expect(document.body.querySelector('[data-testid="command-palette"]')).toBeNull();
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
+ });
+ const input = document.body.querySelector(
+ '[data-testid="command-palette-input"]',
+ ) as HTMLInputElement;
+ expect(input.value).toBe('');
+ });
+
+ it('shows an empty state for unmatched queries', () => {
+ render();
+ openViaTrigger();
+ setQuery('zzz-no-such-thing');
+ expect(optionLabels()).toEqual([]);
+ expect(document.body.textContent).toContain('No results');
+ });
+
+ it('navigates to the /zh sibling and renders Chinese labels on /zh pages', () => {
+ mockPathname = '/zh/glossary';
+ render();
+ openViaTrigger();
+ const input = document.body.querySelector(
+ '[data-testid="command-palette-input"]',
+ ) as HTMLInputElement;
+ expect(input.placeholder).toContain('搜索');
+ setQuery('首页');
+ pressOnInput('Enter');
+ expect(push).toHaveBeenCalledWith('/zh');
+ });
+
+ it('treats selecting the current page as a no-op', () => {
+ // Re-pushing the same route would only wipe live dashboard filters.
+ mockPathname = '/zh';
+ render();
+ openViaTrigger();
+ setQuery('首页');
+ pressOnInput('Enter');
+ expect(push).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app/src/components/ui/command-palette.tsx b/packages/app/src/components/ui/command-palette.tsx
new file mode 100644
index 000000000..de56a3f07
--- /dev/null
+++ b/packages/app/src/components/ui/command-palette.tsx
@@ -0,0 +1,436 @@
+'use client';
+
+import {
+ CornerDownLeftIcon,
+ ExternalLinkIcon,
+ LanguagesIcon,
+ SearchIcon,
+ SunMoonIcon,
+} from 'lucide-react';
+import { usePathname, useRouter } from 'next/navigation';
+import { useTheme } from 'next-themes';
+import * as React from 'react';
+
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { track } from '@/lib/analytics';
+import {
+ buildPaletteNavItems,
+ PALETTE_GROUP_LABELS,
+ type PaletteGroupKey,
+ type PaletteNavItem,
+} from '@/lib/command-palette-items';
+import { hasZhSibling, switchLocalePath, zhPath } from '@/lib/i18n';
+import { pushInApp } from '@/lib/client-navigation';
+import { useClientPathname } from '@/hooks/useClientPathname';
+import { useClientSearch } from '@/hooks/useClientSearch';
+import { useLocale } from '@/lib/use-locale';
+import { cn } from '@/lib/utils';
+import { matchesSearch } from '@/lib/search-match';
+
+const STRINGS = {
+ en: {
+ triggerLabel: 'Search and navigate',
+ triggerText: 'Search',
+ dialogTitle: 'Search and navigate',
+ placeholder: 'Search pages, models, chips…',
+ noResults: 'No results for',
+ noResultsHint: 'Try a model, chip, or page name — e.g. “kimi k3” or “b300”.',
+ actions: 'Actions',
+ switchTheme: 'Switch theme',
+ switchThemeKeywords: 'dark light mode color 主题 深色 浅色',
+ switchLocale: '切换到中文版',
+ switchLocaleKeywords: 'chinese language locale 中文 语言',
+ github: 'Star on GitHub',
+ githubKeywords: 'repository source code star 开源 仓库',
+ navigate: 'navigate',
+ open: 'open',
+ close: 'close',
+ },
+ zh: {
+ triggerLabel: '搜索与导航',
+ triggerText: '搜索',
+ dialogTitle: '搜索与导航',
+ placeholder: '搜索页面、模型、芯片…',
+ noResults: '没有匹配结果:',
+ noResultsHint: '试试模型、芯片或页面名称,例如 “kimi k3” 或 “b300”。',
+ actions: '操作',
+ switchTheme: '切换主题',
+ switchThemeKeywords: 'switch theme dark light mode 深色 浅色',
+ switchLocale: 'Switch to English',
+ switchLocaleKeywords: 'english language locale 英文 语言',
+ github: '在 GitHub 上加星',
+ githubKeywords: 'github repository source code star 开源 仓库',
+ navigate: '导航',
+ open: '打开',
+ close: '关闭',
+ },
+} as const;
+
+const GITHUB_URL = 'https://github.com/SemiAnalysisAI/InferenceX';
+
+const THEME_CYCLE = ['light', 'dark', 'minecraft'] as const;
+
+interface ActionItem {
+ id: string;
+ label: string;
+ keywords: string;
+ icon: React.ComponentType<{ className?: string }>;
+ run: () => void;
+}
+
+interface FlatEntry {
+ key: string;
+ label: string;
+ /** Right-aligned secondary text (nav destination path). */
+ hint?: string;
+ icon?: React.ComponentType<{ className?: string }>;
+ select: () => void;
+}
+
+interface Section {
+ label: string;
+ entries: FlatEntry[];
+}
+
+/**
+ * Global command palette: ⌘K / Ctrl+K (or the header search button) opens a
+ * dialog that jumps to any page, dashboard tab, model, or chip page, plus a
+ * few actions. Filtering shares `matchesSearch` with every other search box,
+ * so punctuation and word order never matter.
+ */
+export function CommandPalette() {
+ const router = useRouter();
+ const routerPathname = usePathname() ?? '/';
+ // Live pathname: per-model dashboard routes rewrite the URL outside the
+ // Next router, which usePathname alone would miss (same as LanguageToggle).
+ const pathname = useClientPathname(routerPathname);
+ const search = useClientSearch();
+ const locale = useLocale();
+ const t = STRINGS[locale];
+ const { setTheme, theme } = useTheme();
+
+ const [open, setOpen] = React.useState(false);
+ const [query, setQuery] = React.useState('');
+ const [activeIndex, setActiveIndex] = React.useState(0);
+ const [isMac, setIsMac] = React.useState(null);
+ const listRef = React.useRef(null);
+ const listboxId = React.useId();
+
+ React.useEffect(() => {
+ setIsMac(/mac|iphone|ipad|ipod/i.test(navigator.platform));
+ }, []);
+
+ const openPalette = React.useCallback((source: 'shortcut' | 'button') => {
+ setOpen(true);
+ track('command_palette_opened', { source });
+ }, []);
+
+ const handleOpenChange = React.useCallback((nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ setQuery('');
+ setActiveIndex(0);
+ }
+ }, []);
+
+ // Mirror of `open` for the document-level shortcut listener, so toggling
+ // goes through handleOpenChange (which resets the query on close).
+ const openRef = React.useRef(false);
+ React.useEffect(() => {
+ openRef.current = open;
+ }, [open]);
+
+ // Global ⌘K / Ctrl+K shortcut. Toggles, so a second press closes.
+ React.useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !event.altKey) {
+ event.preventDefault();
+ if (openRef.current) {
+ handleOpenChange(false);
+ } else {
+ handleOpenChange(true);
+ track('command_palette_opened', { source: 'shortcut' });
+ }
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [handleOpenChange]);
+
+ const navItems = React.useMemo(() => buildPaletteNavItems(locale), [locale]);
+
+ // Dashboard tab jumps carry the unofficialruns param, same as TabNav.
+ const unofficialIds = React.useMemo(() => {
+ for (const [key, value] of new URLSearchParams(search)) {
+ if (/^unofficialruns?$/iu.test(key) && value) return value;
+ }
+ return '';
+ }, [search]);
+
+ const selectNav = React.useCallback(
+ (item: PaletteNavItem) => {
+ const target = locale === 'zh' && hasZhSibling(item.href) ? zhPath(item.href) : item.href;
+ track('command_palette_selected', { id: item.id, query });
+ handleOpenChange(false);
+ // Selecting the current page is a no-op — a refetch would only wipe the
+ // dashboard filters (header links behave the same way).
+ if (target === pathname) return;
+ const href =
+ item.group === 'dashboard' && unofficialIds
+ ? `${target}?unofficialruns=${unofficialIds}`
+ : target;
+ pushInApp(router, href);
+ },
+ [locale, query, router, handleOpenChange, pathname, unofficialIds],
+ );
+
+ const actionItems = React.useMemo(
+ () => [
+ {
+ id: 'action:theme',
+ label: t.switchTheme,
+ keywords: t.switchThemeKeywords,
+ icon: SunMoonIcon,
+ run: () => {
+ const idx = THEME_CYCLE.indexOf(theme as (typeof THEME_CYCLE)[number]);
+ const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length];
+ setTheme(next);
+ track('theme_toggled', { theme: next });
+ },
+ },
+ {
+ id: 'action:locale',
+ label: t.switchLocale,
+ keywords: t.switchLocaleKeywords,
+ icon: LanguagesIcon,
+ run: () => {
+ // Same contract as the header language toggle: keep the current
+ // query string (dashboard filters) and use the commit-retry push.
+ pushInApp(router, switchLocalePath(pathname) + search);
+ },
+ },
+ {
+ id: 'action:github',
+ label: t.github,
+ keywords: t.githubKeywords,
+ icon: ExternalLinkIcon,
+ run: () => {
+ window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
+ },
+ },
+ ],
+ [t, theme, setTheme, router, pathname, search],
+ );
+
+ const sections = React.useMemo(() => {
+ const navSections = (Object.keys(PALETTE_GROUP_LABELS) as PaletteGroupKey[]).map((group) => ({
+ label: PALETTE_GROUP_LABELS[group][locale],
+ entries: navItems
+ .filter((item) => item.group === group && matchesSearch(query, item.label, item.keywords))
+ .map((item) => ({
+ key: item.id,
+ label: item.label,
+ hint: item.href,
+ select: () => selectNav(item),
+ })),
+ }));
+ const actions: Section = {
+ label: t.actions,
+ entries: actionItems
+ .filter((action) => matchesSearch(query, action.label, action.keywords))
+ .map((action) => ({
+ key: action.id,
+ label: action.label,
+ icon: action.icon,
+ select: () => {
+ track('command_palette_selected', { id: action.id, query });
+ handleOpenChange(false);
+ action.run();
+ },
+ })),
+ };
+ return [...navSections, actions].filter((section) => section.entries.length > 0);
+ }, [navItems, actionItems, query, locale, t, selectNav, handleOpenChange]);
+
+ const flatEntries = React.useMemo(() => sections.flatMap((s) => s.entries), [sections]);
+
+ // Clamp/reset the active row whenever the result set changes.
+ React.useEffect(() => {
+ setActiveIndex(0);
+ }, [query]);
+ const clampedIndex = Math.min(activeIndex, Math.max(0, flatEntries.length - 1));
+
+ const scrollRowIntoView = (index: number) => {
+ const row = listRef.current?.querySelector(`[data-palette-index="${index}"]`);
+ // scrollIntoView is missing from some DOM test environments.
+ if (row && typeof row.scrollIntoView === 'function') row.scrollIntoView({ block: 'nearest' });
+ };
+
+ const moveActive = (delta: number) => {
+ if (flatEntries.length === 0) return;
+ const next = (clampedIndex + delta + flatEntries.length) % flatEntries.length;
+ setActiveIndex(next);
+ scrollRowIntoView(next);
+ };
+
+ const handleInputKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ moveActive(1);
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ moveActive(-1);
+ } else if (event.key === 'Home' && flatEntries.length > 0) {
+ event.preventDefault();
+ setActiveIndex(0);
+ scrollRowIntoView(0);
+ } else if (event.key === 'End' && flatEntries.length > 0) {
+ event.preventDefault();
+ setActiveIndex(flatEntries.length - 1);
+ scrollRowIntoView(flatEntries.length - 1);
+ } else if (event.key === 'Enter') {
+ // Ignore the Enter that confirms an IME composition (CJK input),
+ // otherwise committing Chinese text would also run the selection.
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
+ event.preventDefault();
+ flatEntries[clampedIndex]?.select();
+ }
+ };
+
+ let rowIndex = -1;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/packages/app/src/components/ui/data-table.tsx b/packages/app/src/components/ui/data-table.tsx
index 0f60c5429..89cb1243d 100644
--- a/packages/app/src/components/ui/data-table.tsx
+++ b/packages/app/src/components/ui/data-table.tsx
@@ -13,6 +13,7 @@ import {
import { useUnofficialDomain } from '@/hooks/useUnofficialDomain';
import { track } from '@/lib/analytics';
+import { matchesSearch } from '@/lib/search-match';
import {
Select,
SelectContent,
@@ -143,15 +144,16 @@ export function DataTable({
setPage(0);
};
- // Search: match against all columns with sortValue
+ // Search: punctuation-insensitive token matching across all columns with a
+ // sortValue, so "B300 vllm" finds a "B300 (vLLM)" row and multi-token
+ // queries can span columns (#406).
const filtered = useMemo(() => {
if (!searchable || !search.trim()) return data;
- const q = search.trim().toLowerCase();
return data.filter((row) =>
- columns.some((col) => {
- if (!col.sortValue) return false;
- return String(col.sortValue(row)).toLowerCase().includes(q);
- }),
+ matchesSearch(
+ search,
+ ...columns.map((col) => (col.sortValue ? String(col.sortValue(row)) : null)),
+ ),
);
}, [data, search, columns, searchable]);
diff --git a/packages/app/src/components/ui/multi-select.tsx b/packages/app/src/components/ui/multi-select.tsx
index 5f5b38e33..ed426f211 100644
--- a/packages/app/src/components/ui/multi-select.tsx
+++ b/packages/app/src/components/ui/multi-select.tsx
@@ -4,6 +4,7 @@ import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from 'lucide-react';
import * as React from 'react';
import { track } from '@/lib/analytics';
+import { matchesSearch } from '@/lib/search-match';
import { cn } from '@/lib/utils';
import { useLocale } from '@/lib/use-locale';
@@ -190,9 +191,9 @@ function MultiSelect({
const filteredSections = React.useMemo(() => {
if (!sections?.length) return null;
- const lower = search.toLowerCase();
+ // Punctuation-insensitive token matching so "B300 vllm" finds "B300 (vLLM)" (#406).
const filterOpts = (opts: MultiSelectOption[]) =>
- search ? opts.filter((opt) => opt.label.toLowerCase().includes(lower)) : opts;
+ search ? opts.filter((opt) => matchesSearch(search, opt.label)) : opts;
return sections.map((section) => ({
...section,
@@ -206,8 +207,7 @@ function MultiSelect({
}
const opts = flatOptions;
if (!search) return opts;
- const lower = search.toLowerCase();
- return opts.filter((opt) => opt.label.toLowerCase().includes(lower));
+ return opts.filter((opt) => matchesSearch(search, opt.label));
}, [filteredSections, flatOptions, search]);
const handleToggle = (optionValue: string) => {
diff --git a/packages/app/src/components/ui/searchable-select.test.ts b/packages/app/src/components/ui/searchable-select.test.ts
index 275774f32..e84b2e851 100644
--- a/packages/app/src/components/ui/searchable-select.test.ts
+++ b/packages/app/src/components/ui/searchable-select.test.ts
@@ -105,6 +105,17 @@ describe('SearchableSelect', () => {
expect(items[0]?.textContent).toContain('Cost per Million Total Tokens (Hyperscaler)');
});
+ it('ignores punctuation and word order in the query (#406)', () => {
+ render();
+ openMenu();
+ // "(Hyperscaler)" is wrapped in parentheses in the option label; a plain
+ // multi-word query must still match, in either token order.
+ setSearchValue('hyperscaler tokens');
+ const items = document.body.querySelectorAll('[data-slot="select-item"]');
+ expect(items).toHaveLength(1);
+ expect(items[0]?.textContent).toContain('Cost per Million Total Tokens (Hyperscaler)');
+ });
+
it('shows a "No results" message when nothing matches', () => {
render();
openMenu();
diff --git a/packages/app/src/components/ui/searchable-select.tsx b/packages/app/src/components/ui/searchable-select.tsx
index 3485208e6..325d69d57 100644
--- a/packages/app/src/components/ui/searchable-select.tsx
+++ b/packages/app/src/components/ui/searchable-select.tsx
@@ -5,6 +5,7 @@ import * as React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { track } from '@/lib/analytics';
+import { matchesSearch } from '@/lib/search-match';
import { cn } from '@/lib/utils';
export interface SearchableSelectOption {
@@ -81,13 +82,12 @@ export function SearchableSelect({
const filteredGroups = React.useMemo(() => {
if (!search) return groups;
- const lower = search.toLowerCase();
return groups
.map((g) => ({
label: g.label,
- options: g.options.filter(
- (opt) => opt.label.toLowerCase().includes(lower) || g.label.toLowerCase().includes(lower),
- ),
+ // Punctuation-insensitive token matching against the option label and
+ // its group label, so "B300 vllm" finds "B300 (vLLM)" (#406).
+ options: g.options.filter((opt) => matchesSearch(search, opt.label, g.label)),
}))
.filter((g) => g.options.length > 0);
}, [groups, search]);
diff --git a/packages/app/src/lib/client-navigation.ts b/packages/app/src/lib/client-navigation.ts
index 9b910a02e..8756f8501 100644
--- a/packages/app/src/lib/client-navigation.ts
+++ b/packages/app/src/lib/client-navigation.ts
@@ -98,6 +98,16 @@ export function navigateInApp(
}
event.preventDefault();
+ pushInApp(router, href);
+}
+
+/**
+ * `router.push` with the same commit-retry as `navigateInApp`, for callers
+ * that navigate without an anchor click (the command palette). See
+ * `navigateInApp` for why the first dashboard transition can request the
+ * route payload without committing the URL change.
+ */
+export function pushInApp(router: RouterLike, href: string): void {
const from = window.location.pathname;
const target = new URL(href, window.location.origin).pathname;
router.push(href);
diff --git a/packages/app/src/lib/command-palette-items.test.ts b/packages/app/src/lib/command-palette-items.test.ts
new file mode 100644
index 000000000..94ef354ea
--- /dev/null
+++ b/packages/app/src/lib/command-palette-items.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+
+import { getAllChipRouteSlugs, getChipPage } from '@/lib/chip-pages';
+import {
+ buildPaletteNavItems,
+ PALETTE_CHIPS,
+ PALETTE_GROUP_LABELS,
+ type PaletteGroupKey,
+} from '@/lib/command-palette-items';
+import { ACTIVE_INFERENCE_MODEL_SLUGS } from '@/lib/inference-model-slug';
+import { hasZhSibling } from '@/lib/i18n';
+import { matchesSearch } from '@/lib/search-match';
+
+describe('buildPaletteNavItems', () => {
+ const en = buildPaletteNavItems('en');
+ const zh = buildPaletteNavItems('zh');
+
+ it('produces unique ids and rooted hrefs', () => {
+ const ids = en.map((item) => item.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const item of en) {
+ expect(item.href.startsWith('/')).toBe(true);
+ expect(item.label.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('mirrors the same catalog across locales', () => {
+ expect(zh.map((item) => item.id)).toEqual(en.map((item) => item.id));
+ expect(zh.map((item) => item.href)).toEqual(en.map((item) => item.href));
+ for (const item of zh) expect(item.label.length).toBeGreaterThan(0);
+ });
+
+ it('covers every group and labels each group in both locales', () => {
+ const groups = new Set(en.map((item) => item.group));
+ for (const key of Object.keys(PALETTE_GROUP_LABELS) as PaletteGroupKey[]) {
+ expect(groups.has(key)).toBe(true);
+ expect(PALETTE_GROUP_LABELS[key].en.length).toBeGreaterThan(0);
+ expect(PALETTE_GROUP_LABELS[key].zh.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('includes one entry per active inference model', () => {
+ const modelItems = en.filter((item) => item.group === 'models');
+ expect(modelItems.map((item) => item.id)).toEqual(
+ ACTIVE_INFERENCE_MODEL_SLUGS.map((m) => `model:${m.slug}`),
+ );
+ for (const item of modelItems) {
+ expect(item.href.startsWith('/inference/')).toBe(true);
+ }
+ });
+
+ it('dashboard tabs are searchable via the header label in both locales', () => {
+ // The header calls this section "Dashboard" / 「仪表板」 — the words users
+ // actually see must hit every dashboard destination.
+ for (const items of [en, zh]) {
+ for (const item of items.filter((i) => i.group === 'dashboard')) {
+ expect(item.keywords).toContain('dashboard');
+ expect(item.keywords).toContain('仪表板');
+ }
+ }
+ });
+
+ it('every href with a zh sibling resolves; dashboard/page hrefs are mirrored', () => {
+ // All palette destinations should exist in the English tree; the mirrored
+ // ones (all of them today) must round-trip through hasZhSibling.
+ for (const item of en) {
+ expect(hasZhSibling(item.href)).toBe(true);
+ }
+ });
+
+ it('finds models from punctuation-less queries via label + keywords', () => {
+ const kimi = en.find((item) => item.id.startsWith('model:kimi-k3'));
+ expect(kimi).toBeDefined();
+ expect(matchesSearch('kimi k3', kimi!.label, kimi!.keywords)).toBe(true);
+ });
+});
+
+describe('PALETTE_CHIPS stays in sync with chip-pages', () => {
+ it('every palette chip slug is a real /chips/[slug] page with a matching title', () => {
+ for (const chip of PALETTE_CHIPS) {
+ const page = getChipPage(chip.slug);
+ expect(page, `unknown chip slug ${chip.slug}`).toBeDefined();
+ expect(chip.label).toBe(page!.title);
+ }
+ });
+
+ it('every chip page is in the palette (versus pages excluded by design)', () => {
+ const paletteSlugs = new Set(PALETTE_CHIPS.map((chip) => chip.slug));
+ const chipOnlySlugs = getAllChipRouteSlugs().filter((slug) => !slug.includes('-vs-'));
+ for (const slug of chipOnlySlugs) {
+ expect(paletteSlugs.has(slug), `chip page ${slug} missing from palette`).toBe(true);
+ }
+ });
+});
diff --git a/packages/app/src/lib/command-palette-items.ts b/packages/app/src/lib/command-palette-items.ts
new file mode 100644
index 000000000..b2a77d6a6
--- /dev/null
+++ b/packages/app/src/lib/command-palette-items.ts
@@ -0,0 +1,128 @@
+/**
+ * Navigation registry for the global command palette (⌘K / Ctrl+K).
+ *
+ * Pure data + builders so the item catalog is unit-testable without React.
+ * The palette component resolves each English `href` to its `/zh` sibling at
+ * selection time (via `hasZhSibling`/`zhPath`), so hrefs here are always the
+ * English path — the same convention the header nav uses.
+ *
+ * Chip entries are a deliberately hardcoded slug/label list instead of an
+ * import from `chip-pages.ts`: that module carries page prose for nine chips
+ * plus every versus page, which the header (and therefore every page) should
+ * not pull into the client bundle for a name list.
+ * `command-palette-items.test.ts` pins every slug against `getAllChipSlugs()`
+ * so the two can never drift.
+ */
+import { DASHBOARD_ROUTES, type DashboardRouteKey } from '@/lib/dashboard-routes';
+import { type Locale } from '@/lib/i18n';
+import { ACTIVE_INFERENCE_MODEL_SLUGS, inferenceModelPath } from '@/lib/inference-model-slug';
+import { TAB_LABELS_EN } from '@/lib/tab-meta';
+import { NAV_LABELS_ZH, TAB_LABELS_ZH } from '@/lib/tab-meta-zh';
+
+export type PaletteGroupKey = 'pages' | 'dashboard' | 'models' | 'chips';
+
+export interface PaletteNavItem {
+ /** Stable id, also used for analytics. */
+ id: string;
+ group: PaletteGroupKey;
+ /** English pathname; the palette maps it to the /zh sibling when needed. */
+ href: `/${string}`;
+ /** Locale-resolved display label. */
+ label: string;
+ /** Extra terms a user might type; matched but never displayed. */
+ keywords?: string;
+}
+
+export const PALETTE_GROUP_LABELS: Record = {
+ pages: { en: 'Pages', zh: '页面' },
+ dashboard: { en: 'Dashboard', zh: '仪表板' },
+ models: { en: 'Models', zh: '模型' },
+ chips: { en: 'Chips', zh: '芯片' },
+};
+
+/** Site pages beyond the header nav. Labels mirror NAV_LABELS_ZH where they exist. */
+const PAGES: readonly { href: `/${string}`; en: string; zh: string; keywords?: string }[] = [
+ { href: '/', en: 'Home', zh: NAV_LABELS_ZH['/'], keywords: 'landing start' },
+ { href: '/agentx', en: 'AgentX', zh: NAV_LABELS_ZH['/agentx'], keywords: 'agentic benchmark' },
+ { href: '/overview', en: 'Overview', zh: NAV_LABELS_ZH['/overview'], keywords: 'matrix summary' },
+ {
+ href: '/compare',
+ en: 'Comparisons',
+ zh: NAV_LABELS_ZH['/compare'],
+ keywords: 'versus vs head to head 对比',
+ },
+ {
+ href: '/blog',
+ en: 'Articles',
+ zh: NAV_LABELS_ZH['/blog'],
+ keywords: 'blog posts news 博客 文章',
+ },
+ { href: '/about', en: 'About', zh: NAV_LABELS_ZH['/about'], keywords: 'methodology faq 关于' },
+ { href: '/chips', en: 'AI Chips', zh: '芯片总览', keywords: 'gpu hardware specs 硬件' },
+ { href: '/rankings', en: 'Rankings', zh: '排行榜', keywords: 'leaderboard best 排名' },
+ { href: '/glossary', en: 'Glossary', zh: '术语表', keywords: 'terms definitions 词汇' },
+ { href: '/quotes', en: 'Supporter Quotes', zh: '支持者评价', keywords: 'endorsements 引用' },
+ { href: '/api', en: 'API Reference', zh: 'API 参考', keywords: 'openapi docs endpoints 文档' },
+];
+
+/**
+ * Chip pages served by /chips/[slug]. Keep in sync with `chip-pages.ts`
+ * (pinned by test, see module doc).
+ */
+export const PALETTE_CHIPS: readonly { slug: string; label: string; keywords: string }[] = [
+ { slug: 'h100', label: 'NVIDIA H100 SXM', keywords: 'hopper gpu' },
+ { slug: 'h200', label: 'NVIDIA H200 SXM', keywords: 'hopper gpu' },
+ { slug: 'b200', label: 'NVIDIA B200', keywords: 'blackwell gpu' },
+ { slug: 'b300', label: 'NVIDIA B300', keywords: 'blackwell ultra gpu' },
+ { slug: 'gb200-nvl72', label: 'NVIDIA GB200 NVL72', keywords: 'blackwell grace rack gpu' },
+ { slug: 'gb300-nvl72', label: 'NVIDIA GB300 NVL72', keywords: 'blackwell ultra grace rack gpu' },
+ { slug: 'mi300x', label: 'AMD Instinct MI300X', keywords: 'cdna gpu' },
+ { slug: 'mi325x', label: 'AMD Instinct MI325X', keywords: 'cdna gpu' },
+ { slug: 'mi355x', label: 'AMD Instinct MI355X', keywords: 'cdna gpu' },
+];
+
+const PRIMARY_TAB_ROUTES = DASHBOARD_ROUTES.filter((route) => route.navGroup === 'primary');
+
+/** Build the full, ordered nav-item catalog for one locale. */
+export function buildPaletteNavItems(locale: Locale): PaletteNavItem[] {
+ const pages: PaletteNavItem[] = PAGES.map((page) => ({
+ id: `page:${page.href}`,
+ group: 'pages',
+ href: page.href,
+ label: locale === 'zh' ? page.zh : page.en,
+ // Keep the other locale's label searchable so e.g. "glossary" still hits on /zh.
+ keywords: [locale === 'zh' ? page.en : page.zh, page.keywords].filter(Boolean).join(' '),
+ }));
+
+ const dashboard: PaletteNavItem[] = PRIMARY_TAB_ROUTES.map((route) => {
+ const key = route.key as DashboardRouteKey;
+ return {
+ id: `tab:${key}`,
+ group: 'dashboard',
+ href: route.path,
+ label: locale === 'zh' ? TAB_LABELS_ZH[key] : TAB_LABELS_EN[key],
+ // Include the header's "Dashboard" label (both locales) so the words
+ // users actually see in the nav also hit these destinations.
+ keywords: `${locale === 'zh' ? TAB_LABELS_EN[key] : TAB_LABELS_ZH[key]} dashboard 仪表板`,
+ };
+ });
+
+ // Model and chip names stay English in both locales (site convention).
+ const models: PaletteNavItem[] = ACTIVE_INFERENCE_MODEL_SLUGS.map((m) => ({
+ id: `model:${m.slug}`,
+ group: 'models',
+ href: inferenceModelPath(m.slug) as `/${string}`,
+ label: m.label,
+ keywords: `${m.seoName} ${m.model} ${m.slug}`,
+ }));
+
+ const chips: PaletteNavItem[] = PALETTE_CHIPS.map((chip) => ({
+ id: `chip:${chip.slug}`,
+ group: 'chips',
+ href: `/chips/${chip.slug}`,
+ label: chip.label,
+ keywords: chip.keywords,
+ }));
+
+ return [...pages, ...dashboard, ...models, ...chips];
+}
diff --git a/packages/app/src/lib/search-match.test.ts b/packages/app/src/lib/search-match.test.ts
new file mode 100644
index 000000000..32570072a
--- /dev/null
+++ b/packages/app/src/lib/search-match.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+
+import { matchesSearch, normalizeSearchText, searchTokens } from '@/lib/search-match';
+
+describe('normalizeSearchText', () => {
+ it('lowercases and folds punctuation to single spaces', () => {
+ expect(normalizeSearchText('B300 (vLLM)')).toBe('b300 vllm');
+ expect(normalizeSearchText('GB200 NVL72 (Dynamo vLLM)')).toBe('gb200 nvl72 dynamo vllm');
+ expect(normalizeSearchText('Cost per Million Total Tokens (Hyperscaler)')).toBe(
+ 'cost per million total tokens hyperscaler',
+ );
+ });
+
+ it('treats dashes, slashes, and dots as separators', () => {
+ expect(normalizeSearchText('DeepSeek-V4-Pro')).toBe('deepseek v4 pro');
+ expect(normalizeSearchText('1k/1k')).toBe('1k 1k');
+ expect(normalizeSearchText('Qwen3.8-Flash-Next')).toBe('qwen3 8 flash next');
+ });
+
+ it('preserves CJK characters', () => {
+ expect(normalizeSearchText('芯片规格')).toBe('芯片规格');
+ expect(normalizeSearchText('术语表 (MI300X)')).toBe('术语表 mi300x');
+ });
+
+ it('collapses to empty for punctuation-only input', () => {
+ expect(normalizeSearchText(' ()-/ ')).toBe('');
+ });
+});
+
+describe('searchTokens', () => {
+ it('splits on whitespace after normalization', () => {
+ expect(searchTokens('B300 vllm')).toEqual(['b300', 'vllm']);
+ expect(searchTokens(' (b300) ')).toEqual(['b300']);
+ });
+
+ it('returns no tokens for blank queries', () => {
+ expect(searchTokens('')).toEqual([]);
+ expect(searchTokens(' ')).toEqual([]);
+ });
+});
+
+describe('matchesSearch', () => {
+ it('matches parenthesized labels from a parenthesis-less query (#406)', () => {
+ expect(matchesSearch('B300 vllm', 'B300 (vLLM)')).toBe(true);
+ expect(matchesSearch('gb200 dynamo', 'GB200 NVL72 (Dynamo vLLM)')).toBe(true);
+ });
+
+ it('is order-independent across tokens', () => {
+ expect(matchesSearch('vllm b300', 'B300 (vLLM)')).toBe(true);
+ });
+
+ it('requires every token to match', () => {
+ expect(matchesSearch('b300 sglang', 'B300 (vLLM)')).toBe(false);
+ });
+
+ it('still supports plain substring matching', () => {
+ expect(matchesSearch('nvl72', 'GB200 NVL72 (Dynamo vLLM)')).toBe(true);
+ expect(matchesSearch('hyper', 'Cost per Million Total Tokens (Hyperscaler)')).toBe(true);
+ });
+
+ it('matches across multiple haystack fields', () => {
+ expect(matchesSearch('throughput input', 'Input Token Throughput per GPU', 'Throughput')).toBe(
+ true,
+ );
+ expect(matchesSearch('cost input', 'Input Token Throughput per GPU', 'Throughput')).toBe(false);
+ });
+
+ it('ignores null/undefined haystack fields', () => {
+ expect(matchesSearch('b300', 'B300 (vLLM)', null, undefined)).toBe(true);
+ });
+
+ it('matches everything on empty or punctuation-only queries', () => {
+ expect(matchesSearch('', 'anything')).toBe(true);
+ expect(matchesSearch(' () ', 'anything')).toBe(true);
+ });
+
+ it('matches queries typed with punctuation against plain labels', () => {
+ expect(matchesSearch('(b300)', 'B300 vLLM')).toBe(true);
+ expect(matchesSearch('deepseek-v4', 'DeepSeek V4 Pro')).toBe(true);
+ });
+});
diff --git a/packages/app/src/lib/search-match.ts b/packages/app/src/lib/search-match.ts
new file mode 100644
index 000000000..9a1870c8a
--- /dev/null
+++ b/packages/app/src/lib/search-match.ts
@@ -0,0 +1,46 @@
+/**
+ * Shared search matching for every search box on the site (selector
+ * dropdowns, legend search, table search, command palette).
+ *
+ * Plain `label.toLowerCase().includes(query)` fails the moment a label
+ * carries punctuation the user doesn't type: "B300 vllm" should match
+ * "B300 (vLLM)" (#406). We normalize punctuation to spaces on BOTH sides
+ * and require every query token to appear somewhere in the haystack, so
+ * word order and bracket style never matter.
+ */
+
+/**
+ * Punctuation that separates words in our labels: brackets, dashes, slashes,
+ * dots, etc. Deliberately NOT `\W` — CJK characters are word characters for
+ * us, and `\W` would strip them.
+ */
+const SEPARATORS = /[()[\]{}<>_\-–—/\\,.:;+&|"'`~!?@#$%^*=]+/g;
+
+/** Lowercase, fold separator punctuation to spaces, collapse whitespace. */
+export function normalizeSearchText(text: string): string {
+ return text.toLowerCase().replace(SEPARATORS, ' ').replaceAll(/\s+/g, ' ').trim();
+}
+
+/** Normalized query tokens; empty array for blank/punctuation-only queries. */
+export function searchTokens(query: string): string[] {
+ const normalized = normalizeSearchText(query);
+ return normalized ? normalized.split(' ') : [];
+}
+
+/**
+ * True when every query token appears (as a substring) in the combined,
+ * normalized haystack fields. An empty query matches everything, mirroring
+ * the previous `if (!search) return all` behavior at every call site.
+ */
+export function matchesSearch(
+ query: string,
+ ...haystacks: readonly (string | null | undefined)[]
+): boolean {
+ const tokens = searchTokens(query);
+ if (tokens.length === 0) return true;
+ const haystack = haystacks
+ .filter((h): h is string => Boolean(h))
+ .map(normalizeSearchText)
+ .join(' ');
+ return tokens.every((token) => haystack.includes(token));
+}
diff --git a/packages/app/src/lib/tab-meta.ts b/packages/app/src/lib/tab-meta.ts
index d959a97d6..ee00ee950 100644
--- a/packages/app/src/lib/tab-meta.ts
+++ b/packages/app/src/lib/tab-meta.ts
@@ -20,6 +20,26 @@ export const LANDING_META = {
"Compare AgentX, InferenceX's long-context, multi-turn coding scenario, with fixed-sequence AI inference across chips and frameworks. Public NVIDIA and AMD runs update when configurations change.",
};
+/**
+ * Short English tab labels, shared by the dashboard tab nav and the command
+ * palette. Chinese siblings live in `TAB_LABELS_ZH` (tab-meta-zh.ts).
+ */
+export const TAB_LABELS_EN: Record = {
+ inference: 'Inference Performance',
+ evaluation: 'Accuracy Evals',
+ historical: 'Historical Trends',
+ calculator: 'TCO Calculator',
+ fleet: 'Fleet Lifecycle',
+ reliability: 'Reliability',
+ 'gpu-specs': 'Chip Specs',
+ submissions: 'Submissions',
+ collectivex: 'CollectiveX',
+ 'ai-chart': 'AI Chart',
+ 'gpu-metrics': 'PowerX',
+ 'current-inferencex-image': 'Images',
+ feedback: 'Feedback',
+};
+
export const TAB_META: Record = {
inference: {
title: 'Agentic Inference Benchmarks',