Rank Settings search results by relevance (#6050)

* Rank settings search results by relevance

* Keep Settings search content matching ranked entries

* rm design doc
This commit is contained in:
Jinjing
2026-06-21 23:54:06 -07:00
committed by GitHub
parent a459100d87
commit bbfaee424b
3 changed files with 295 additions and 34 deletions
@@ -51,7 +51,7 @@ import { AdvancedPane } from './AdvancedPane'
import { SettingsSidebar } from './SettingsSidebar'
import { SettingsSetupGuidePane } from './SettingsSetupGuidePane'
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
import { matchesSettingsSearch } from './settings-search'
import { getSettingsSectionSearchEntries, rankSettingsSearchItems } from './settings-search'
import { cn } from '@/lib/utils'
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
import { registerWindowCloseGuard } from '../window-close-request-coordinator'
@@ -135,6 +135,12 @@ const SETTINGS_NAV_GROUPS = [
}
] as const
type SettingsNavGroupDefinition = (typeof SETTINGS_NAV_GROUPS)[number]
const SETTINGS_NAV_GROUP_BY_ID = new Map<string, SettingsNavGroupDefinition>(
SETTINGS_NAV_GROUPS.map((group) => [group.id, group])
)
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
@@ -149,6 +155,27 @@ function getFallbackVisibleSection(sections: SettingsNavSection[]): SettingsNavS
return sections.at(0)
}
function getSettingsNavGroupDefinitionsForSearch(
sections: readonly SettingsNavSection[],
query: string
): readonly SettingsNavGroupDefinition[] {
if (query.trim() === '') {
return SETTINGS_NAV_GROUPS
}
const seenGroupIds = new Set<string>()
return sections.flatMap((section) => {
if (section.id.startsWith('repo-') || seenGroupIds.has(section.group)) {
return []
}
const group = SETTINGS_NAV_GROUP_BY_ID.get(section.group)
if (!group) {
return []
}
seenGroupIds.add(section.group)
return [group]
})
}
function getSkillNavInstallStatus(skill: {
installed: boolean
loading: boolean
@@ -632,21 +659,26 @@ function Settings(): React.JSX.Element {
() => new Map(navSections.map((section) => [section.id, section] as const)),
[navSections]
)
const getSectionSearchEntries = (sectionId: string) =>
navSectionById.get(sectionId)?.searchEntries ?? []
const getSectionSearchEntries = (sectionId: string) => {
const section = navSectionById.get(sectionId)
return section ? getSettingsSectionSearchEntries(section) : []
}
const visibleNavSections = useMemo(
() =>
navSections.filter((section) =>
section.id === 'git' && hasUnsavedSourceControlAiPromptChanges
? true
: matchesSettingsSearch(settingsSearchQuery, [
{ title: section.title, description: section.description },
...section.searchEntries
])
),
[hasUnsavedSourceControlAiPromptChanges, navSections, settingsSearchQuery]
)
const visibleNavSections = useMemo(() => {
const rankedSections = rankSettingsSearchItems(
settingsSearchQuery,
navSections,
getSettingsSectionSearchEntries
).map(({ item }) => item)
if (
!hasUnsavedSourceControlAiPromptChanges ||
rankedSections.some((section) => section.id === 'git')
) {
return rankedSections
}
const gitSection = navSectionById.get('git')
return gitSection ? [...rankedSections, gitSection] : rankedSections
}, [hasUnsavedSourceControlAiPromptChanges, navSectionById, navSections, settingsSearchQuery])
const visibleSectionIds = useMemo(
() => new Set(visibleNavSections.map((section) => section.id)),
[visibleNavSections]
@@ -964,11 +996,17 @@ function Settings(): React.JSX.Element {
}
const generalNavSections = visibleNavSections.filter((section) => !section.id.startsWith('repo-'))
const generalNavGroups: SettingsNavGroup[] = SETTINGS_NAV_GROUPS.map((group) => ({
id: group.id,
title: translate(group.titleKey, group.titleDefault),
sections: generalNavSections.filter((section) => section.group === group.id)
})).filter((group) => group.sections.length > 0 || group.id === 'setup')
const generalNavGroupDefinitions = getSettingsNavGroupDefinitionsForSearch(
visibleNavSections,
settingsSearchQuery
)
const generalNavGroups: SettingsNavGroup[] = generalNavGroupDefinitions
.map((group) => ({
id: group.id,
title: translate(group.titleKey, group.titleDefault),
sections: generalNavSections.filter((section) => section.group === group.id)
}))
.filter((group) => group.sections.length > 0 || group.id === 'setup')
const repoNavSections = visibleNavSections
.filter((section) => section.id.startsWith('repo-'))
.map((section) => {
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest'
import {
SETTINGS_SEARCH_QUERY_MAX_BYTES,
getSettingsSectionSearchEntries,
isSettingsSearchQueryTooLarge,
matchesSettingsSearch,
normalizeSettingsSearchQuery,
rankSettingsSearchItems,
scoreSettingsSearch,
type SettingsSearchEntry
} from './settings-search'
@@ -28,6 +31,101 @@ describe('settings-search', () => {
expect(matchesSettingsSearch(' ', { title: 'General' })).toBe(true)
})
it('scores pane title matches above entry, description, and keyword matches', () => {
const paneTitleMatch = [{ title: 'Keyboard Shortcuts' }]
const entryTitleMatch = [{ title: 'Interface' }, { title: 'Shortcuts' }]
const descriptionMatch = [{ title: 'Interface', description: 'Shortcuts' }]
const keywordMatch = [{ title: 'Interface', keywords: ['shortcuts'] }]
expect(scoreSettingsSearch('shortcuts', paneTitleMatch)).toBeGreaterThan(
scoreSettingsSearch('shortcuts', entryTitleMatch)
)
expect(scoreSettingsSearch('shortcuts', entryTitleMatch)).toBeGreaterThan(
scoreSettingsSearch('shortcuts', descriptionMatch)
)
expect(scoreSettingsSearch('shortcuts', descriptionMatch)).toBeGreaterThan(
scoreSettingsSearch('shortcuts', keywordMatch)
)
})
it('ranks Shortcuts above Task Sources for the shortcuts query', () => {
const sections = [
{
id: 'tasks',
entries: [
{ title: 'Task Sources', description: 'Choose task providers.' },
{
title: 'Task Providers',
description:
'Choose which task providers appear in the Tasks page and sidebar shortcuts.'
}
]
},
{
id: 'shortcuts',
entries: [
{ title: 'Shortcuts', description: 'Keyboard shortcuts for common actions.' },
{ title: 'Shortcuts in Terminal', description: 'Choose terminal shortcut behavior.' }
]
}
]
expect(
rankSettingsSearchItems('shortcuts', sections, (section) => section.entries).map(
(section) => section.item.id
)
).toEqual(['shortcuts', 'tasks'])
})
it('includes pane title entries for SettingsSection content filtering', () => {
const section = {
title: 'AI Provider Accounts',
description: 'Optional account switching.',
searchEntries: [{ title: 'Claude', description: 'Use the signed-in Claude account.' }]
}
const entries = getSettingsSectionSearchEntries(section)
expect(matchesSettingsSearch('accounts', entries)).toBe(true)
expect(matchesSettingsSearch('accounts', section.searchEntries)).toBe(false)
})
it('preserves source order for equally ranked matches', () => {
const sections = [
{ id: 'terminal', entries: [{ title: 'Terminal' }] },
{ id: 'terminal-advanced', entries: [{ title: 'Terminal Advanced' }] }
]
expect(
rankSettingsSearchItems('term', sections, (section) => section.entries).map(
(section) => section.item.id
)
).toEqual(['terminal', 'terminal-advanced'])
})
it('keeps empty search ranking in source order without reading entries', () => {
const sections = [
{
id: 'general',
get entries(): SettingsSearchEntry[] {
throw new Error('empty settings searches must not scan entries')
}
},
{
id: 'shortcuts',
get entries(): SettingsSearchEntry[] {
throw new Error('empty settings searches must not scan entries')
}
}
]
expect(
rankSettingsSearchItems(' ', sections, (section) => section.entries).map(
(section) => section.item.id
)
).toEqual(['general', 'shortcuts'])
})
it('rejects oversized pasted searches before reading settings entries', () => {
const oversizedQuery = 'secret-settings-search'.repeat(SETTINGS_SEARCH_QUERY_MAX_BYTES)
const entry = {
@@ -44,6 +142,8 @@ describe('settings-search', () => {
expect(isSettingsSearchQueryTooLarge(oversizedQuery)).toBe(true)
expect(matchesSettingsSearch(oversizedQuery, entry)).toBe(false)
expect(scoreSettingsSearch(oversizedQuery, entry)).toBe(0)
expect(rankSettingsSearchItems(oversizedQuery, [entry], (item) => item)).toEqual([])
})
it('rejects oversized whitespace before trimming settings searches', () => {
@@ -9,6 +9,49 @@ export type SettingsSearchEntry = {
}
export const SETTINGS_SEARCH_QUERY_MAX_BYTES = 2 * 1024
const SETTINGS_SEARCH_NO_MATCH_SCORE = 0
const SETTINGS_SEARCH_EMPTY_QUERY_SCORE = 1
type SettingsSearchScoreTier = {
exact: number
prefix: number
substring: number
}
type SettingsSearchRankCandidate<T> = {
item: T
index: number
score: number
}
export type RankedSettingsSearchItem<T> = {
item: T
score: number
}
const PANE_TITLE_SCORE: SettingsSearchScoreTier = {
exact: 900,
prefix: 850,
substring: 800
}
const ENTRY_TITLE_SCORE: SettingsSearchScoreTier = {
exact: 700,
prefix: 650,
substring: 600
}
const DESCRIPTION_SCORE: SettingsSearchScoreTier = {
exact: 500,
prefix: 450,
substring: 400
}
const KEYWORD_SCORE: SettingsSearchScoreTier = {
exact: 300,
prefix: 250,
substring: 200
}
export function isSettingsSearchQueryTooLarge(
query: string,
@@ -21,22 +64,102 @@ export function normalizeSettingsSearchQuery(query: string): string {
return query.trim().toLowerCase()
}
function scoreSettingsSearchText(
normalizedQuery: string,
value: string | undefined,
tier: SettingsSearchScoreTier
): number {
if (!value) {
return SETTINGS_SEARCH_NO_MATCH_SCORE
}
const normalizedValue = value.toLowerCase()
if (normalizedValue === normalizedQuery) {
return tier.exact
}
if (normalizedValue.startsWith(normalizedQuery)) {
return tier.prefix
}
if (normalizedValue.includes(normalizedQuery)) {
return tier.substring
}
return SETTINGS_SEARCH_NO_MATCH_SCORE
}
function scoreSettingsSearchValues(
normalizedQuery: string,
values: readonly string[] | undefined,
tier: SettingsSearchScoreTier
): number {
return (values ?? []).reduce(
(score, value) => Math.max(score, scoreSettingsSearchText(normalizedQuery, value, tier)),
SETTINGS_SEARCH_NO_MATCH_SCORE
)
}
export function scoreSettingsSearch(
query: string,
entries: SettingsSearchEntry | SettingsSearchEntry[]
): number {
if (isSettingsSearchQueryTooLarge(query)) {
return SETTINGS_SEARCH_NO_MATCH_SCORE
}
const normalizedQuery = normalizeSettingsSearchQuery(query)
if (!normalizedQuery) {
return SETTINGS_SEARCH_EMPTY_QUERY_SCORE
}
const values = Array.isArray(entries) ? entries : [entries]
return values.reduce((score, entry, index) => {
// Why: Settings passes the pane entry first so pane-title hits outrank
// lower-level setting titles without adding a second search-entry shape.
const titleScore = index === 0 ? PANE_TITLE_SCORE : ENTRY_TITLE_SCORE
return Math.max(
score,
scoreSettingsSearchText(normalizedQuery, entry.title, titleScore),
scoreSettingsSearchText(normalizedQuery, entry.description, DESCRIPTION_SCORE),
scoreSettingsSearchValues(normalizedQuery, entry.keywords, KEYWORD_SCORE)
)
}, SETTINGS_SEARCH_NO_MATCH_SCORE)
}
export function getSettingsSectionSearchEntries(section: {
title: string
description: string
searchEntries: readonly SettingsSearchEntry[]
}): SettingsSearchEntry[] {
// Why: sidebar ranking and active content filtering must receive the same
// pane-level entry, otherwise pane-title-only hits can rank but render blank.
return [{ title: section.title, description: section.description }, ...section.searchEntries]
}
export function rankSettingsSearchItems<T>(
query: string,
items: readonly T[],
getEntries: (item: T) => SettingsSearchEntry | SettingsSearchEntry[]
): RankedSettingsSearchItem<T>[] {
if (isSettingsSearchQueryTooLarge(query)) {
return []
}
if (!normalizeSettingsSearchQuery(query)) {
return items.map((item) => ({ item, score: SETTINGS_SEARCH_EMPTY_QUERY_SCORE }))
}
return items
.map(
(item, index): SettingsSearchRankCandidate<T> => ({
item,
index,
score: scoreSettingsSearch(query, getEntries(item))
})
)
.filter((candidate) => candidate.score > SETTINGS_SEARCH_NO_MATCH_SCORE)
.sort((a, b) => b.score - a.score || a.index - b.index)
.map(({ item, score }) => ({ item, score }))
}
export function matchesSettingsSearch(
query: string,
entries: SettingsSearchEntry | SettingsSearchEntry[]
): boolean {
if (isSettingsSearchQueryTooLarge(query)) {
return false
}
const trimmedQuery = query.trim()
if (!trimmedQuery) {
return true
}
const normalizedQuery = trimmedQuery.toLowerCase()
const values = Array.isArray(entries) ? entries : [entries]
return values.some((entry) => {
const haystack = [entry.title, entry.description ?? '', ...(entry.keywords ?? [])]
return haystack.some((value) => value.toLowerCase().includes(normalizedQuery))
})
return scoreSettingsSearch(query, entries) > SETTINGS_SEARCH_NO_MATCH_SCORE
}