From 3cf39940428ad8e55452992f272c155db1136b24 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:54:01 +0000 Subject: [PATCH 1/4] feat: add global command palette and punctuation-insensitive search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Cmd/Ctrl+K command palette in the header that jumps to any page, dashboard tab, model dashboard, or chip page (bilingual, /zh aware), and a shared punctuation-insensitive multi-token matcher used by the palette and every existing search box (searchable-select, multi-select, chart legend, data table), so queries like "B300 vllm" match "B300 (vLLM)". Closes #406. 中文:新增全局命令面板(Cmd/Ctrl+K),可从页眉快速跳转到任意页面、仪表板 标签页、模型仪表板或芯片页面(支持中英双语与 /zh 路由);同时引入统一的 忽略标点的多词搜索匹配工具,并应用于所有现有搜索框(下拉搜索、多选、 图表图例、数据表格),例如查询 "B300 vllm" 可匹配 "B300 (vLLM)"。 修复 #406。 --- packages/app/src/components/header/header.tsx | 2 + packages/app/src/components/tab-nav.tsx | 17 +- .../components/ui/command-palette.test.tsx | 138 ++++++ .../app/src/components/ui/command-palette.tsx | 405 ++++++++++++++++++ packages/app/src/components/ui/data-table.tsx | 14 +- .../app/src/components/ui/multi-select.tsx | 8 +- .../components/ui/searchable-select.test.ts | 11 + .../src/components/ui/searchable-select.tsx | 8 +- packages/app/src/lib/client-navigation.ts | 10 + .../app/src/lib/command-palette-items.test.ts | 83 ++++ packages/app/src/lib/command-palette-items.ts | 126 ++++++ packages/app/src/lib/search-match.test.ts | 81 ++++ packages/app/src/lib/search-match.ts | 46 ++ packages/app/src/lib/tab-meta.ts | 20 + 14 files changed, 939 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/components/ui/command-palette.test.tsx create mode 100644 packages/app/src/components/ui/command-palette.tsx create mode 100644 packages/app/src/lib/command-palette-items.test.ts create mode 100644 packages/app/src/lib/command-palette-items.ts create mode 100644 packages/app/src/lib/search-match.test.ts create mode 100644 packages/app/src/lib/search-match.ts diff --git a/packages/app/src/components/header/header.tsx b/packages/app/src/components/header/header.tsx index 4b8608df6..74c642dff 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,7 @@ export const Header = ({ starCount }: { starCount?: number | null }) => { {/* Right side */}
+ 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..5734d0a76 --- /dev/null +++ b/packages/app/src/components/ui/command-palette.test.tsx @@ -0,0 +1,138 @@ +// @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('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'; + 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'); + }); +}); 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..5757fdf85 --- /dev/null +++ b/packages/app/src/components/ui/command-palette.tsx @@ -0,0 +1,405 @@ +'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 { 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 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 }); + }, []); + + // 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(); + setOpen((prev) => { + if (!prev) track('command_palette_opened', { source: 'shortcut' }); + return !prev; + }); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(''); + setActiveIndex(0); + } + }, []); + + const navItems = React.useMemo(() => buildPaletteNavItems(locale), [locale]); + + 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); + pushInApp(router, target); + }, + [locale, query, router, handleOpenChange], + ); + + 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: () => { + router.push(switchLocalePath(pathname)); + }, + }, + { + id: 'action:github', + label: t.github, + keywords: t.githubKeywords, + icon: ExternalLinkIcon, + run: () => { + window.open(GITHUB_URL, '_blank', 'noopener,noreferrer'); + }, + }, + ], + [t, theme, setTheme, router, pathname], + ); + + 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) => { + listRef.current + ?.querySelector(`[data-palette-index="${index}"]`) + ?.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') { + event.preventDefault(); + flatEntries[clampedIndex]?.select(); + } + }; + + let rowIndex = -1; + + return ( + <> + + + + + {t.dialogTitle} +
+
+ +
+ {flatEntries.length === 0 ? ( +
+

+ {t.noResults} “{query}” +

+

{t.noResultsHint}

+
+ ) : ( + sections.map((section) => ( +
+
+ {section.label} +
+ {section.entries.map((entry) => { + rowIndex += 1; + const index = rowIndex; + const isActive = index === clampedIndex; + const Icon = entry.icon; + return ( +
setActiveIndex(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={entry.select} + > + {Icon && } + {entry.label} + {entry.hint && ( + + {entry.hint} + + )} + {isActive && !entry.hint && ( +
+ ); + })} +
+ )) + )} +
+ +
+ + ↑↓{' '} + {t.navigate} + + + {' '} + {t.open} + + + esc{' '} + {t.close} + +
+
+
+ + ); +} 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..9792e0008 --- /dev/null +++ b/packages/app/src/lib/command-palette-items.test.ts @@ -0,0 +1,83 @@ +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('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..532008ffa --- /dev/null +++ b/packages/app/src/lib/command-palette-items.ts @@ -0,0 +1,126 @@ +/** + * 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], + keywords: locale === 'zh' ? TAB_LABELS_EN[key] : TAB_LABELS_ZH[key], + }; + }); + + // 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', From 4cb311e3d8064ed1327bd92d688217a0bdf63c12 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:01:39 +0000 Subject: [PATCH 2/4] fix: guard scrollIntoView for test DOMs and hide palette trigger under 360px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cypress component tests pin the 320px header at capacity, and the CI DOM environment lacks Element.scrollIntoView. 中文:为测试 DOM 环境增加 scrollIntoView 保护,并在窄于 360px 的视口下 隐藏命令面板触发按钮(320px 页眉已无剩余空间)。 --- packages/app/src/components/header/header.tsx | 6 +++++- packages/app/src/components/ui/command-palette.tsx | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/header/header.tsx b/packages/app/src/components/header/header.tsx index 74c642dff..728ac21e6 100644 --- a/packages/app/src/components/header/header.tsx +++ b/packages/app/src/components/header/header.tsx @@ -254,7 +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/ui/command-palette.tsx b/packages/app/src/components/ui/command-palette.tsx index 5757fdf85..ff463687c 100644 --- a/packages/app/src/components/ui/command-palette.tsx +++ b/packages/app/src/components/ui/command-palette.tsx @@ -233,9 +233,9 @@ export function CommandPalette() { const clampedIndex = Math.min(activeIndex, Math.max(0, flatEntries.length - 1)); const scrollRowIntoView = (index: number) => { - listRef.current - ?.querySelector(`[data-palette-index="${index}"]`) - ?.scrollIntoView({ block: 'nearest' }); + 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) => { From 6fa7e56c3f4c759bb362c4240656c2ac5e8e48da Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:10:10 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20Bugbot=20review=20?= =?UTF-8?q?=E2=80=94=20IME=20Enter,=20locale=20query=20params,=20shortcut?= =?UTF-8?q?=20close=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ignore Enter while an IME composition is being confirmed (isComposing / keyCode 229), so committing CJK input no longer runs the selection - Locale action now keeps the current query string and uses the same commit-retry push as the header language toggle - Closing via Cmd/Ctrl+K now routes through handleOpenChange so the next open starts with a cleared query 中文:修复 Bugbot 审查发现的三个问题——输入法(IME)确认组合输入时不再 触发选中项;语言切换操作保留当前查询参数并复用页眉语言切换的导航重试 逻辑;通过快捷键关闭面板时同样清空搜索词。 --- .../components/ui/command-palette.test.tsx | 35 ++++++++++++++ .../app/src/components/ui/command-palette.tsx | 46 +++++++++++++------ 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/ui/command-palette.test.tsx b/packages/app/src/components/ui/command-palette.test.tsx index 5734d0a76..fbc98bcce 100644 --- a/packages/app/src/components/ui/command-palette.test.tsx +++ b/packages/app/src/components/ui/command-palette.test.tsx @@ -115,6 +115,41 @@ describe('CommandPalette', () => { 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(); diff --git a/packages/app/src/components/ui/command-palette.tsx b/packages/app/src/components/ui/command-palette.tsx index ff463687c..8b0889d43 100644 --- a/packages/app/src/components/ui/command-palette.tsx +++ b/packages/app/src/components/ui/command-palette.tsx @@ -22,6 +22,7 @@ import { 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'; @@ -103,6 +104,7 @@ export function CommandPalette() { // 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(); @@ -123,28 +125,37 @@ export function CommandPalette() { 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(); - setOpen((prev) => { - if (!prev) track('command_palette_opened', { source: 'shortcut' }); - return !prev; - }); + if (openRef.current) { + handleOpenChange(false); + } else { + handleOpenChange(true); + track('command_palette_opened', { source: 'shortcut' }); + } } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, []); - - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(''); - setActiveIndex(0); - } - }, []); + }, [handleOpenChange]); const navItems = React.useMemo(() => buildPaletteNavItems(locale), [locale]); @@ -178,7 +189,9 @@ export function CommandPalette() { keywords: t.switchLocaleKeywords, icon: LanguagesIcon, run: () => { - router.push(switchLocalePath(pathname)); + // 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); }, }, { @@ -191,7 +204,7 @@ export function CommandPalette() { }, }, ], - [t, theme, setTheme, router, pathname], + [t, theme, setTheme, router, pathname, search], ); const sections = React.useMemo(() => { @@ -261,6 +274,9 @@ export function CommandPalette() { 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(); } From 275bec50f59077f3b05932e87535a4dac9463212 Mon Sep 17 00:00:00 2001 From: functionstackx <47992694+functionstackx@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:45:04 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20Bugbot=20round=202=20?= =?UTF-8?q?=E2=80=94=20same-page=20no-op,=20unofficialruns,=20dashboard=20?= =?UTF-8?q?keywords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selecting the current page in the palette is now a no-op instead of a refetch that wipes dashboard filters (matches header link behavior) - Dashboard tab jumps carry the unofficialruns param, same as TabNav - Dashboard destinations are searchable via the header label the user actually sees: "dashboard" and 仪表板 in both locales 中文:修复 Bugbot 第二轮审查——在面板中选择当前页面不再重新加载(避免 清空仪表板筛选条件);仪表板标签跳转保留 unofficialruns 参数;仪表板 条目可通过页眉实际显示的「Dashboard/仪表板」关键词搜索到。 --- .../components/ui/command-palette.test.tsx | 12 +++++++++++- .../app/src/components/ui/command-palette.tsx | 19 +++++++++++++++++-- .../app/src/lib/command-palette-items.test.ts | 11 +++++++++++ packages/app/src/lib/command-palette-items.ts | 4 +++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/ui/command-palette.test.tsx b/packages/app/src/components/ui/command-palette.test.tsx index fbc98bcce..52962d5c0 100644 --- a/packages/app/src/components/ui/command-palette.test.tsx +++ b/packages/app/src/components/ui/command-palette.test.tsx @@ -159,7 +159,7 @@ describe('CommandPalette', () => { }); it('navigates to the /zh sibling and renders Chinese labels on /zh pages', () => { - mockPathname = '/zh'; + mockPathname = '/zh/glossary'; render(); openViaTrigger(); const input = document.body.querySelector( @@ -170,4 +170,14 @@ describe('CommandPalette', () => { 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 index 8b0889d43..de56a3f07 100644 --- a/packages/app/src/components/ui/command-palette.tsx +++ b/packages/app/src/components/ui/command-palette.tsx @@ -159,14 +159,29 @@ export function CommandPalette() { 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); - pushInApp(router, target); + // 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], + [locale, query, router, handleOpenChange, pathname, unofficialIds], ); const actionItems = React.useMemo( diff --git a/packages/app/src/lib/command-palette-items.test.ts b/packages/app/src/lib/command-palette-items.test.ts index 9792e0008..94ef354ea 100644 --- a/packages/app/src/lib/command-palette-items.test.ts +++ b/packages/app/src/lib/command-palette-items.test.ts @@ -49,6 +49,17 @@ describe('buildPaletteNavItems', () => { } }); + 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. diff --git a/packages/app/src/lib/command-palette-items.ts b/packages/app/src/lib/command-palette-items.ts index 532008ffa..b2a77d6a6 100644 --- a/packages/app/src/lib/command-palette-items.ts +++ b/packages/app/src/lib/command-palette-items.ts @@ -101,7 +101,9 @@ export function buildPaletteNavItems(locale: Locale): PaletteNavItem[] { group: 'dashboard', href: route.path, label: locale === 'zh' ? TAB_LABELS_ZH[key] : TAB_LABELS_EN[key], - keywords: locale === 'zh' ? TAB_LABELS_EN[key] : TAB_LABELS_ZH[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 仪表板`, }; });