Skip to content

Commit 9cb4e29

Browse files
fix(app-shell): the command palette matches navigation entries on word prefixes and substrings (objectui#11812) (#11883)
Fixes #11812 Clause-②: no ## What changes The ⌘K palette's navigation groups (Objects, Dashboards, Pages, Reports, Switch App) and its theme and full-search commands were matched by cmdk's default subsequence scorer: `CommandDialog` mounts cmdk's `Command` with no `filter`, so every item goes through `commandScore`. The palette now matches those entries itself and renders only the matches. - **One module-private matcher**, `matchesPaletteQuery` in `packages/app-shell/src/chrome/paletteMatch.ts` (not re-exported). An entry matches when the query, ignoring case, starts a word of its label or machine name (`field` finds "Field Zoo", `zoo` finds `showcase_field_zoo`), or, from three characters on, is a contiguous substring of one of them (`view` finds "Task Overview"). A hyphen and a space read alike, which is cmdk's own normalisation. No typo tolerance. - **Han, Hiragana and Katakana** are written without spaces, so each of their characters starts a word: `大屏` and `屏` both find "Command Center (大屏)" (Zone 2 item 4, pinned). - **`CommandPalette`** filters each navigation group, the app list, the three theme commands and the full-search command through it, and renders a group, with its separator, only when something in it matched. - **No cmdk `value` changes.** Each rendered entry carries its label and name in its value, so the trimmed query is a contiguous run of that value and cmdk's scorer, which still ranks what is rendered and decides when `CommandEmpty` shows, accepts every entry the palette rendered. "No results" now shows exactly when no entry matched and the record search found nothing. - **Record hits are untouched**: the same item, value (the query embedded), group and order. ### The mechanism, measured (Zone 2 item 1: confirmed) cmdk 1.1.1's `commandScore`, run on the showcase entries' values: | query | value | score | |---|---|---| | `zzzz` | `object Field Zoo showcase_field_zoo` | 9.900e-3 | | `zzzz` | `page New Project (Wizard) showcase_new_project_wizard` | 9.900e-3 | | `ingest` | `object In-Progress Tasks showcase_task` | 2.305e-2 | | `ingest` | `object Cascading Select showcase_cascade` | 2.853e-3 | | `ingest` | `page Page Authoring showcase_start_here` | 2.271e-2 | | `ingest` | `page Styling (ADR-0065) showcase_styling_gallery` | 2.246e-2 | | `wayne` | `page Styling (ADR-0065) showcase_styling_gallery` | 1.386e-4 | These are exactly the false hits the card's QA pass read, so the scorer alone explains them. `zzzz` scores above zero because the scorer may skip a query letter that repeats the next one (a 0.1 factor each time), so the two `z`s of "Field Zoo ... zoo" absorb four. ### Route taken (Zone 2 item 2) - **Taken:** the palette matches, and renders only the matches, inside `CommandPalette.tsx`. Neither `forceMount` nor a query carried in the value was needed, because every entry the matcher accepts already scores above zero in cmdk. `forceMount` was read in cmdk 1.1.1's source and set aside: a force-mounted item that cmdk scores 0 is rendered but not counted in `filtered.count`, so "No results" could show beside it. - **Not taken:** composing `Dialog` and `Command` from `@object-ui/components` with a `filter`. Both are exported, but that copies `CommandDialog`'s composition (the visually-hidden title and description of ADR-0054 C4, the cmdk class string) out of the Shadcn-synced `ui/command.tsx` into app-shell, and a `filter` sees only a value string, so record hits would need a kind marker in their value to stay exempt. A `filter` passthrough on `CommandDialog` would be a new prop on a published component (a Clause-② stop) in `ui/**` (AGENTS.md rule 7). `CommandDialog` is not edited; the `overlay:command-palette` test id and the accessible name are untouched (pinned by the empty-query control). ### Theme and full-search commands (Zone 2 item 3: they follow the rule) They are matched by the same rule over the words they were already matched by (`theme light`, `theme dark`, `theme system`, `search all results full page`). Under the old scorer scattered letters reached them too: `hsy` scored 1.515e-1 on `theme system`, `tht` 1.686e-1 on `theme light`. ### Behaviour changes a reviewer should know - **Initials no longer match.** `fz` found "Field Zoo" and `sap` found the full-search command (8.003e-1) through scattered word starts; neither is a word prefix or a substring, so neither matches now. This is the card's *Expected*. - **The group's type word no longer matches.** `object`, `page`, `dashboard`, `report` and `app` stay at the head of each cmdk value but are not a matching term: `page` now finds "Page Authoring" and "Page Variables", not every page. Matching is on what the entry is called, its label and machine name, per the triage direction. ## Real-browser reading (Chromium 141, 1440×900) The console's Vite dev server served a temporary probe page, deleted afterwards and never committed, that mounts the real `CommandPaletteProvider` (opened by `?palette=1`) and the real `CommandPalette` under `I18nProvider` in `en`, over the showcase app's whole navigation (its 50 leaf entries, read from objectstack `examples/app-showcase`, `ShowcaseApp.navigation`), with a `searchAll` stub holding three records (Ingest pipeline, Wayne Enterprises, 华宁科技). Queries were typed key by key. *Before* is `CommandPalette.tsx` at this branch's base `5d69133`; *after* is `a76ebb5`. | query | before | after | |---|---|---| | `zzzz` | Objects: Field Zoo · Pages: New Project (Wizard) | "No results found." | | `ingest` | Task: Ingest pipeline · Objects: In-Progress Tasks, Cascading Select · Pages: Page Authoring, Styling (ADR-0065) | Task: Ingest pipeline | | `wayne` | Account: Wayne Enterprises · Pages: Styling (ADR-0065) | Account: Wayne Enterprises | | `field` | Objects: Field Zoo | Objects: Field Zoo | | `大屏` | Pages: Command Center (大屏) | Pages: Command Center (大屏) | No page errors in either run. The showcase backend was not booted: the sister objectstack checkout is not built in this container, so the record hits come from the stub. ## Tests (at `a76ebb5`) - `paletteMatch.test.ts` (new, 13 cases): the seven scattered pairs from the card do not match; word prefixes of the label and of the machine name match at any length; a mid-word substring matches from three characters on and not before; phrases, hyphen and space, case; Han and kana substrings; blank query and missing terms. - `CommandPalette.navMatch-11812.test.tsx` (new, 7 cases), through the real `CommandPaletteProvider` and cmdk: `zzzz` shows no entry and "No results found." once the record search settles; `ingest` shows its record hit and only the entry whose machine name contains it; `wayne` shows Wayne Enterprises and no page; `field`, `view`, `大屏` and `屏` each show their entry; apps, theme and full-search commands follow the rule. Controls: the record hit's item is the one the palette always rendered (`record wayne Wayne Enterprises Account showcase_account a1`), and an empty query shows every entry, app and command. - The existing palette suites (`CommandPalette.navLabelInherits-9868`, `CommandPalette.searchLabelFls-10500`) pass unchanged: no cmdk value moved. ### Ablation (direction: red, as expected) `node scripts/ablation-replace.mjs` (objectstack's tool, run from this worktree) replaced the `matches` helper with one that accepts every entry (anchor 1 → 0, blob `77b858975656` → `0168da5f2e1f`), so cmdk's default scorer decides again, and ran the DOM suite: **5 failed, 2 passed**. The five pins went red with the card's own readings (`zzzz`: `['Field Zoo', 'New Project (Wizard)']`; `ingest`: In-Progress Tasks, Cascading Select, Page Authoring and Styling (ADR-0065) beside Replay Queue; `wayne`: Styling (ADR-0065)); the two controls stayed green. Restore: blob back to `77b858975656` = HEAD, `git diff HEAD` empty, marker count 0. The suite imports `./CommandPalette` from source, so no `dist` sat between the mutation and the run. ## Gates (all at `a76ebb5`) | command | exit | verdict line | |---|---|---| | `pnpm exec vitest run packages/app-shell/` | 0 | Test Files 1097 passed, 1 skipped (1098) · Tests 10803 passed, 9 skipped | | `pnpm --filter @object-ui/app-shell type-check` (script name echoed; `tsconfig.test.json` lists all four touched files) | 0 | `tsc --noEmit && tsc -p tsconfig.test.json` | | `pnpm --filter @object-ui/app-shell build` | 0 | dist completeness: 1 package(s) complete | | `pnpm exec eslint` on the 4 touched files | 0 | 0 errors; 13 warnings, all in `CommandPalette.tsx`, rule-for-rule the same 13 as on base `5d69133` | | `pnpm check:control-bytes` | 0 | OK | | `pnpm check:test-path-roots` | 0 | OK | | `pnpm check:changeset-claims` | 0 | No pending changeset names a file this change touches | | `pnpm check:pending-changeset-literals` | 0 | No test source names a pending changeset | | `pnpm check:new-line-citations` | 0 | 0 new citation(s) | | `pnpm check:i18n-keys` | 0 | Every in-scope call-site key resolves against the en pack | | `pnpm check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` | 0 | OK | | `pnpm check:unreferenced-sources` · `check:self-import` · `check:phantom-deps` · `check:esm-specifiers` | 0 | OK | | `node scripts/check-changeset-presence.mjs` · `check-changeset-no-major.mjs` | 0 | 1 changeset declared; no `major` | The eslint run is a narrowing, not the repo-wide `pnpm lint` (CI's): eslint's own config globs `**/*.{ts,tsx}` cover all four files; its `--format json` output lists 4 files, none ignored; and `eslint.config.js` enables no type-aware linting (no `projectService` or `parserOptions.project`), so this diff cannot move the verdict of a file it does not touch. Locale packs and package exports are unchanged, so the i18n pack gates and `check:readme-exports` do not apply. NOT MEASURED locally: `check:node-esm-load` (its load leg builds the tree), left to CI. The public surface is unchanged: the built `dist/index.d.ts` and `dist/chrome/index.d.ts` do not reach `paletteMatch.d.ts`; no export, prop, type member or language-pack key is added. Patch changeset: `.changeset/11812-palette-nav-match.md`. `main` has moved to `04c8f56` since the base; none of those commits touches `packages/app-shell/src/chrome/`, `ui/command.tsx`, `useRecordSearch` or `CommandPaletteProvider`, and `git merge-tree` against it is clean, so the branch was not merged. ## Acceptance notes - The theme commands are matched by their English words in every locale, as before: a zh viewer typing `主题` finds none of them, because their translated labels were never in their values. Unchanged here; noted, not filed. Carrier: none. - The record search (`useRecordSearch`, the server call) and the `?palette=1` deep link are untouched, per the triage ruling. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` (dispatched by the `domain:ui` seat 3 PM). --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4373484 commit 9cb4e29

5 files changed

Lines changed: 491 additions & 42 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The ⌘K command palette lists a navigation entry only when the query is a word prefix or a contiguous substring of its label or machine name, so a query that matches nothing shows "No results" (objectui#11812).
6+
7+
The Objects, Dashboards, Pages, Reports and Switch App groups went through cmdk's default subsequence scorer, which matches letters scattered across an entry. On the showcase app `zzzz` listed "Field Zoo" and "New Project (Wizard)", `ingest` listed "In-Progress Tasks", "Cascading Select", "Page Authoring" and "Styling (ADR-0065)" next to the real record hit, and `wayne` listed "Styling (ADR-0065)" next to Wayne Enterprises.
8+
9+
- **An entry matches** when the query, ignoring case, starts a word of its label or machine name (`field` finds "Field Zoo", `zoo` finds `showcase_field_zoo`), or, from three characters on, appears anywhere in one of them (`view` finds "Task Overview"). A hyphen and a space read alike. There is no typo tolerance.
10+
- **Chinese and Japanese labels**: Han, Hiragana and Katakana are written without spaces, so each of their characters starts a word, and a one-character query finds a label that holds it anywhere.
11+
- **The theme and "Open Full Search Page" commands** follow the same rule over the words they were already searched by (`theme light`, `search all results full page`).
12+
- **Record hits are unchanged**: the server search finds them, and each is listed as before.
13+
14+
Nothing is added to the package entry: no export, prop, type member or language-pack key.
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The ⌘K palette shows a navigation entry only when the query is a word
11+
* prefix, or a contiguous substring, of its label or machine name
12+
* (objectui#11812).
13+
*
14+
* The navigation groups used to go through cmdk's default subsequence scorer,
15+
* so on the showcase app `zzzz` listed "Field Zoo" and "New Project (Wizard)",
16+
* `ingest` listed four entries made of scattered letters next to the real
17+
* record hit, and a nonsense query never reached "No results". The palette now
18+
* matches those entries itself (`matchesPaletteQuery`) and renders only the
19+
* matches; the theme and full-search commands follow the same rule over their
20+
* values. Record hits are unchanged: the server search finds them.
21+
*
22+
* Pinned through the real `CommandPaletteProvider` (opened by its `?palette=1`
23+
* deep link) under a real `I18nProvider` in `en`, over the showcase's own
24+
* labels, with a `searchAll` stub standing in for the server search. What is pinned is what the DOM holds, since cmdk
25+
* leaves an item it filters out unrendered:
26+
*
27+
* - `zzzz` shows no entry and "No results", once the record search settles;
28+
* - `ingest` and `wayne` show their record hit and no unrelated entry;
29+
* `ingest` shows the one entry whose machine name contains it;
30+
* - a word prefix (`field`), a mid-word substring (`view`) and a Han
31+
* substring (`大屏`) each show their entry, so cmdk's scorer, which still
32+
* ranks the rendered entries, never hides a match;
33+
* - CONTROLS: the record hit's item is the one the palette always rendered,
34+
* and an empty query shows every entry and command.
35+
*/
36+
37+
import * as React from 'react';
38+
import { describe, it, expect, vi, afterEach } from 'vitest';
39+
import { render, fireEvent, waitFor, cleanup, screen } from '@testing-library/react';
40+
import { MemoryRouter } from 'react-router-dom';
41+
import { createI18n, I18nProvider } from '@object-ui/i18n';
42+
43+
vi.mock('@object-ui/auth', async (importOriginal) => ({
44+
...(await importOriginal<Record<string, unknown>>()),
45+
useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
46+
}));
47+
48+
import { CommandPalette } from './CommandPalette';
49+
import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
50+
51+
// The showcase app's entries the QA pass read, in its own nesting, plus one
52+
// entry whose machine name (not its label) contains `ingest`.
53+
const SHOWCASE = {
54+
name: 'showcase_app',
55+
label: 'Showcase',
56+
navigation: [
57+
{ id: 'nav_start_here', type: 'page', pageName: 'showcase_start_here', label: 'Page Authoring' },
58+
{
59+
id: 'grp_workspace',
60+
type: 'group',
61+
label: 'Workspace',
62+
children: [
63+
{ id: 'nav_new_project_wizard', type: 'page', pageName: 'showcase_new_project_wizard', label: 'New Project (Wizard)' },
64+
],
65+
},
66+
{
67+
id: 'grp_data',
68+
type: 'group',
69+
label: 'Data Model',
70+
children: [
71+
{ id: 'nav_tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' },
72+
{ id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts' },
73+
{ id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo' },
74+
{ id: 'nav_cascade', type: 'object', objectName: 'showcase_cascade', label: 'Cascading Select' },
75+
{ id: 'nav_slice_in_progress', type: 'object', objectName: 'showcase_task', label: 'In-Progress Tasks' },
76+
],
77+
},
78+
{
79+
id: 'grp_analytics',
80+
type: 'group',
81+
label: 'Analytics',
82+
children: [
83+
{ id: 'nav_command_center', type: 'page', pageName: 'showcase_command_center', label: 'Command Center (大屏)' },
84+
{ id: 'nav_ops', type: 'dashboard', dashboardName: 'showcase_ops_dashboard', label: 'Delivery Operations' },
85+
{ id: 'nav_report_joined', type: 'report', reportName: 'showcase_task_overview', label: 'Task Overview' },
86+
],
87+
},
88+
{ id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)' },
89+
{ id: 'nav_replay', type: 'page', pageName: 'ops_reingest_queue', label: 'Replay Queue' },
90+
],
91+
};
92+
const CRM = { name: 'crm_app', label: 'CRM', navigation: [] };
93+
94+
const OBJECTS = [
95+
{ name: 'showcase_task', label: 'Task' },
96+
{ name: 'showcase_account', label: 'Account' },
97+
{ name: 'showcase_field_zoo', label: 'Field Zoo' },
98+
{ name: 'showcase_cascade', label: 'Cascading Select' },
99+
];
100+
101+
/** The records the server search knows, found by a plain substring of their title. */
102+
const RECORDS = [
103+
{ object: 'showcase_task', id: 't1', title: 'Ingest pipeline' },
104+
{ object: 'showcase_account', id: 'a1', title: 'Wayne Enterprises' },
105+
];
106+
107+
function mount(query: string) {
108+
const dataSource = {
109+
find: vi.fn(async () => ({ data: [] })),
110+
searchAll: vi.fn(async (q: string) => ({
111+
hits: RECORDS.filter((r) => r.title.toLowerCase().includes(q.toLowerCase())),
112+
})),
113+
};
114+
render(
115+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
116+
<MemoryRouter initialEntries={['/apps/showcase_app?palette=1']}>
117+
<CommandPaletteProvider>
118+
<CommandPalette
119+
apps={[SHOWCASE, CRM]}
120+
activeApp={SHOWCASE}
121+
objects={OBJECTS}
122+
onAppChange={() => {}}
123+
dataSource={dataSource}
124+
/>
125+
</CommandPaletteProvider>
126+
</MemoryRouter>
127+
</I18nProvider>,
128+
);
129+
const input = document.querySelector('[cmdk-input]');
130+
if (!input) throw new Error('the palette did not open');
131+
if (query) fireEvent.change(input, { target: { value: query } });
132+
return dataSource;
133+
}
134+
135+
/** The visible text of every item cmdk rendered whose value starts with `kind `. */
136+
function shown(kind: string): string[] {
137+
return Array.from(document.querySelectorAll(`[cmdk-item][data-value^="${kind} "]`)).map((el) =>
138+
(el.textContent ?? '').trim(),
139+
);
140+
}
141+
142+
/** Every navigation entry, app and command cmdk rendered: everything but record hits. */
143+
function entries(): string[] {
144+
return ['object', 'dashboard', 'page', 'report', 'app', 'theme', 'search'].flatMap(shown);
145+
}
146+
147+
/** Waits for the debounced record search to answer, then returns the hits' text. */
148+
async function settledRecordHits(dataSource: { searchAll: ReturnType<typeof vi.fn> }): Promise<string[]> {
149+
await waitFor(() => expect(dataSource.searchAll).toHaveBeenCalled(), { timeout: 4000 });
150+
await waitFor(() => expect(document.body.textContent).not.toContain('Searching…'), { timeout: 4000 });
151+
return shown('record');
152+
}
153+
154+
afterEach(() => cleanup());
155+
156+
describe('objectui#11812 — the ⌘K palette matches navigation entries on word prefixes and substrings', () => {
157+
it('`zzzz` shows no entry and, once the record search finds nothing, "No results"', async () => {
158+
const ds = mount('zzzz');
159+
expect(await settledRecordHits(ds)).toEqual([]);
160+
expect(entries()).toEqual([]);
161+
const empty = document.querySelector('[cmdk-empty]');
162+
expect(empty).not.toBeNull();
163+
expect(empty!.textContent).toBe('No results found.');
164+
});
165+
166+
it('`ingest` shows its record hit and only the entry whose machine name contains it', async () => {
167+
const ds = mount('ingest');
168+
expect(await settledRecordHits(ds)).toEqual(['Ingest pipeline']);
169+
expect(entries()).toEqual(['Replay Queue']);
170+
expect(document.querySelector('[cmdk-empty]')).toBeNull();
171+
});
172+
173+
it('`wayne` shows Wayne Enterprises and no page', async () => {
174+
const ds = mount('wayne');
175+
expect(await settledRecordHits(ds)).toEqual(['Wayne Enterprises']);
176+
expect(entries()).toEqual([]);
177+
});
178+
179+
it('a word prefix, a mid-word substring and a Han substring each show their entry', () => {
180+
mount('field');
181+
expect(entries()).toEqual(['Field Zoo']);
182+
cleanup();
183+
184+
mount('view');
185+
expect(entries()).toEqual(['Task Overview']);
186+
cleanup();
187+
188+
mount('大屏');
189+
expect(entries()).toEqual(['Command Center (大屏)']);
190+
cleanup();
191+
192+
mount('屏');
193+
expect(entries()).toEqual(['Command Center (大屏)']);
194+
});
195+
196+
it('apps, the theme commands and the full-search command follow the same rule', () => {
197+
mount('crm');
198+
expect(shown('app')).toEqual(['CRM']);
199+
expect(shown('theme')).toEqual([]);
200+
cleanup();
201+
202+
mount('dark');
203+
expect(entries()).toEqual(['Dark Theme']);
204+
cleanup();
205+
206+
mount('theme');
207+
expect(shown('theme')).toEqual(['Light Theme', 'Dark Theme', 'System Theme']);
208+
cleanup();
209+
210+
mount('search');
211+
expect(entries()).toEqual(['Open Full Search Page']);
212+
});
213+
214+
it('CONTROL: the record hit is the item the palette always rendered, its value carrying the query', async () => {
215+
const ds = mount('wayne');
216+
await settledRecordHits(ds);
217+
const hit = document.querySelector('[cmdk-item][data-value^="record "]');
218+
expect(hit?.getAttribute('data-value')).toBe('record wayne Wayne Enterprises Account showcase_account a1');
219+
});
220+
221+
it('CONTROL: an empty query shows every entry, app and command', () => {
222+
mount('');
223+
expect(shown('object')).toEqual(['Tasks', 'Accounts', 'Field Zoo', 'Cascading Select', 'In-Progress Tasks']);
224+
expect(shown('page')).toEqual([
225+
'Page Authoring',
226+
'New Project (Wizard)',
227+
'Command Center (大屏)',
228+
'Styling (ADR-0065)',
229+
'Replay Queue',
230+
]);
231+
expect(shown('dashboard')).toEqual(['Delivery Operations']);
232+
expect(shown('report')).toEqual(['Task Overview']);
233+
expect(shown('app')).toEqual(['ShowcaseCurrent', 'CRM']);
234+
expect(shown('theme')).toEqual(['Light Theme', 'Dark Theme', 'System Theme']);
235+
expect(shown('search')).toEqual(['Open Full Search Page']);
236+
expect(screen.getByTestId('overlay:command-palette')).toBeTruthy();
237+
});
238+
});

0 commit comments

Comments
 (0)