mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user