From 1aaa5cfbd91b028791aeb8f49d46156c24ffc385 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:40:14 -0700 Subject: [PATCH] Fix cmd+j search ranking to require coverage of meaningful query words (#12954) * Fix cmd+j search ranking to require coverage of meaningful query words Extract tokenization logic into a shared module to ensure consistent ranking across settings and project search bands. Enhance ranking with: - Coverage requirement: candidates must match most meaningful words, not just one (fixes "linear triage" matching all projects on "linear" alone) - Filler words: ignore navigation words like "open", "go", "the" when measuring coverage - Unicode support: split on Unicode word boundaries, not ASCII only * Fix cmd+j search to require query coverage and handle Unicode The search ranking now requires all query words to match candidate values before applying shortcut rules, preventing false positives where middle words could be ignored. Query normalization now iterates over Unicode characters instead of code units to properly lowercase supplementary-plane characters. --- .../cmd-j/palette-project-results.ts | 88 +++---------- .../cmd-j/palette-query-tokens.test.ts | 15 +++ .../components/cmd-j/palette-query-tokens.ts | 116 ++++++++++++++++++ .../components/cmd-j/palette-results.test.ts | 77 ++++++++++++ .../src/components/cmd-j/palette-results.ts | 96 +++------------ 5 files changed, 243 insertions(+), 149 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-query-tokens.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-query-tokens.ts diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index e4b26441b..d0fcc1563 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -1,4 +1,9 @@ import { isCmdJPaletteQueryTooLarge } from './palette-results' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { @@ -39,73 +44,6 @@ type RankedProjectResult = { const PROJECT_GROUP_ALIASES = ['group', 'repo group'] const PROJECT_ALIASES = ['project', 'repo'] -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function buildCmdJProjectSearchCandidates({ projectGroups, repos, @@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({ ), rowKey: getProjectGroupHeaderKey(group.id), order, - keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES]) }) }) @@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({ rowKey: target.key, repo: target.repo, order: projectGroups.length + repoIndex, - keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([ + target.label, + repo.displayName, + ...PROJECT_ALIASES + ]) }) }) @@ -191,7 +133,7 @@ function projectRankingForCandidate( query: string, candidate: CmdJProjectSearchResult ): RankedProjectResult | null { - const title = normalizeQuery(candidate.title) + const title = normalizeCmdJPaletteQuery(candidate.title) if (query === title) { return { result: candidate, rule: 1, score: 0 } } @@ -199,13 +141,13 @@ function projectRankingForCandidate( return { result: candidate, rule: 2, score: 0 } } const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES - if (aliasKeywords.map(normalizeQuery).includes(query)) { + if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) { return { result: candidate, rule: 3, score: 0 } } if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { return { result: candidate, rule: 4, score: 0 } } - const score = tokenScore(query, [candidate.title, ...candidate.keywords]) + const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords]) return score > 0 ? { result: candidate, rule: 5, score } : null } @@ -242,7 +184,7 @@ export function searchCmdJProjectResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) // Why: project/group rows sit after worktree matches, so one-character // searches would add broad noisy navigation targets before intent is clear. if (normalizedQuery.length < 2) { diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts new file mode 100644 index 000000000..3f829ace1 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCmdJPaletteQuery, tokenizeCmdJPaletteQuery } from './palette-query-tokens' + +describe('Cmd+J palette query normalization', () => { + it('lowercases supplementary-plane characters as whole code points', () => { + // Why: code-unit iteration split '\u{10400}' into surrogate halves, which lowercase to + // themselves, so the query never matched its lowercase counterpart. + expect(normalizeCmdJPaletteQuery('\u{10400} HELLO')).toBe('\u{10428} hello') + expect(tokenizeCmdJPaletteQuery('\u{10400} HELLO')).toEqual(['\u{10428}', 'hello']) + }) + + it('collapses surrounding and repeated whitespace', () => { + expect(normalizeCmdJPaletteQuery(' New\n\tTerminal ')).toBe('new terminal') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.ts new file mode 100644 index 000000000..3ba9068b0 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.ts @@ -0,0 +1,116 @@ +// Why: the middle band and the project band scored queries with byte-identical copies of +// this logic, so ranking fixes only ever landed in one of the two. + +function isCmdJPaletteWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +// Why: iterating code units would lowercase surrogate halves separately, leaving +// supplementary-plane characters uncased and unmatchable. +export function normalizeCmdJPaletteQuery(value: string): string { + let normalized = '' + let pendingWhitespace = false + for (const character of value) { + if (isCmdJPaletteWhitespace(character.codePointAt(0) ?? 0)) { + pendingWhitespace = normalized.length > 0 + continue + } + if (pendingWhitespace) { + normalized += ' ' + pendingWhitespace = false + } + normalized += character.toLowerCase() + } + return normalized +} + +export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))] +} + +// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized +// query would silently skip the coverage rule below instead of failing it. +export function tokenizeCmdJPaletteQuery(value: string): string[] { + return normalizeCmdJPaletteQuery(value) + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean) +} + +// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an +// unmatched filler word must not count against a candidate's coverage. +const CMD_J_QUERY_FILLER_TOKENS = new Set([ + 'a', + 'an', + 'and', + 'change', + 'edit', + 'for', + 'from', + 'go', + 'goto', + 'in', + 'into', + 'jump', + 'me', + 'my', + 'of', + 'on', + 'open', + 'please', + 'set', + 'show', + 'the', + 'to', + 'view', + 'with' +]) + +export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + let meaningful = 0 + let covered = 0 + for (const queryToken of tokenizeCmdJPaletteQuery(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) { + continue + } + meaningful += 1 + if (best > 0) { + covered += 1 + } + } + + // Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate + // now has to cover most of what was typed, not just one word of it. + if (meaningful > 0 && covered * 2 <= meaningful) { + return 0 + } + return score +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 73049bcc1..bf875ebe9 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -208,6 +208,66 @@ describe('Cmd+J palette middle-band ranking', () => { }) }) + it('drops candidates that cover a minority of the words typed', () => { + // Why: "linear triage" used to surface the Linear and Integrations panes on the + // "linear" token alone, with the unmatched word costing nothing. See screenshot report. + const integrationSections: SettingsNavSection[] = [ + { + id: 'linear', + title: 'Linear', + description: 'How Linear works in Orca.', + icon: Settings, + searchEntries: [], + group: 'capabilities' + }, + { + id: 'integrations', + title: 'Integrations', + description: 'Connect GitHub, GitLab, and Linear.', + icon: Settings, + searchEntries: [], + group: 'setup' + } + ] + const rank = (query: string): string[] => + rankCmdJMiddleResults({ + query, + settingsResults: buildCmdJSettingsResults(integrationSections), + actionResults: [] + }).map((result) => result.id) + + expect(rank('linear triage')).toEqual([]) + expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations']) + // Why: a majority still counts, so one stray word cannot blank an otherwise good match. + expect(rank('linear integrations triage')).toEqual(['settings:integrations']) + }) + + it('drops verb-prefixed settings queries whose middle words match nothing', () => { + // Why: the verb-plus-settings-keyword rule reads only the head and tail of the query, so + // "new terminal browser settings" used to win on those two ends alone. + expect(top('new terminal sparkles glitter browser settings')).toBeUndefined() + expect(top('new terminal settings')).toBe('settings:terminal') + }) + + it('keeps out-of-order partial-word matches when every query word lands somewhere', () => { + expect(top('font term')).toBe('settings:terminal') + expect(top('font sparkles')).toBeUndefined() + }) + + it('ignores navigation filler words when measuring coverage', () => { + // Why: "open"/"go to" carry no intent, so they must not read as unmatched words + // and blank the band mid-query. + expect(top('open terminal settings')).toBe('settings:terminal') + expect(top('go to ssh settings')).toBe('settings:ssh') + expect(top('change the terminal font')).toBe('settings:terminal') + }) + + it('counts non-latin query words instead of discarding them', () => { + // Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely. + expect(top('ssh 主机 设置')).toBeUndefined() + expect(top('ssh 设置')).toBeUndefined() + }) + it('does not match settings on one-character or description-only queries', () => { expect(top('t')).toBeUndefined() expect(top('cookie import')).toBeUndefined() @@ -316,6 +376,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n } describe('Cmd+J project and repo-group search', () => { + it('drops projects that only match the query through a generic alias word', () => { + // Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to + // match all of them on that word alone — the same bug the middle band had. + const search = (query: string): string[] => + searchCmdJProjectResults({ + query, + projectGroups: [], + repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')], + projects: [], + projectHostSetups: [] + }).map((result) => result.title) + + expect(search('repo triage')).toEqual([]) + expect(search('linear triage')).toEqual([]) + expect(search('linear repo')).toEqual(['linear-sync']) + }) + it('finds a Project Group by name', () => { const [result] = searchCmdJProjectResults({ query: 'infra', diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 924659984..3b643c1fe 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -1,6 +1,11 @@ import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types' import type { CmdJQuickAction } from './quick-actions' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' export type CmdJSettingsResult = { id: string @@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') @@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] { return [entryTitle, `${entryTitle} settings`] } -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - export function buildCmdJSettingsResults( sections: readonly SettingsNavSection[] ): CmdJSettingsResult[] { @@ -123,7 +90,7 @@ export function buildCmdJSettingsResults( icon: section.icon, sectionId: section.id, order, - configKeywords: uniqueNormalized(keywordParts(section)) + configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section)) } const targetedResults = section.searchEntries .filter((entry) => entry.targetSectionId) @@ -136,7 +103,7 @@ export function buildCmdJSettingsResults( sectionId: section.id, targetSectionId: entry.targetSectionId, order: order + (entryIndex + 1) / 100, - configKeywords: uniqueNormalized([ + configKeywords: uniqueNormalizedCmdJPaletteKeywords([ ...targetEntryKeywordParts(entry.title), ...(entry.cmdJKeywords ?? entry.keywords ?? []) ]) @@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean { return keyword.startsWith(query) || query.startsWith(keyword) } -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function rankingForCandidate( query: string, candidate: CmdJMiddleResult, @@ -193,6 +131,17 @@ function rankingForCandidate( return null } + // Why: the shortcut rules below only inspect the head and tail of the query, so coverage has + // to gate all of them — otherwise "new terminal browser settings" still wins on rule 3. + const values = + candidate.kind === 'settings' + ? [candidate.title, ...candidate.configKeywords] + : [candidate.title, ...candidate.verbKeywords] + const score = cmdJPaletteTokenScore(query, values) + if (score === 0) { + return null + } + if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) { return { result: candidate, rule: 1, score: 0 } } @@ -227,12 +176,7 @@ function rankingForCandidate( return { result: candidate, rule: 5, score: 0 } } - const values = - candidate.kind === 'settings' - ? [candidate.title, ...candidate.configKeywords] - : [candidate.title, ...candidate.verbKeywords] - const score = tokenScore(query, values) - return score > 0 ? { result: candidate, rule: 6, score } : null + return { result: candidate, rule: 6, score } } function compareRanked(a: RankedResult, b: RankedResult): number { @@ -263,7 +207,7 @@ export function rankCmdJMiddleResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) if (normalizedQuery.length < 2) { return [] }