From 51944fa75406f4e4950650ded3ceb4252beff528 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 10:28:11 -0700 Subject: [PATCH] fix(skills): the Settings list and the composer picker match a query word by word (F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA finding F5's third and fourth copies. Both surfaces filtered installed skills by asking whether the WHOLE lowercased query occurred inside one field, so a phrase naming two installed skills found neither, and a one-letter query found every row whose prose held that letter. Measured on this tree before the change, over the rows the two pickers render: `R scripting ggplot visualization` listed 0 of 3 rows on both surfaces while `ggplot` alone listed 1; `R` listed 2 of 2 rows, one of them `markdown-render`, which holds the letter twice and means nothing by it. In the composer that second case is a bulk write, not just a long list: "Enable all" writes every row the filter left on screen, and it read "Enable all (2)". Both now go through `skills/searchCatalog.ts`, which is not a matcher — it is the field list. The rules stay in `baam/search.ts`, the port of `crates/biorouter/src/catalog_search.rs`, so these two pickers, the Browse modals and the model's own `skills__searchSkills` read the same words the same way. Two decisions the shared matcher could not settle, both recorded in the new file and pinned by its tests: - A bundle ROW has no counterpart in Rust, where a member's name is the Name field of its own entry. Member names are searched at Label: on a bundle row a member's name is not what the row is called, it is a label saying what the row contains. Measured for the query `ggplot`, a skill of its own by that name scores 39 against a package containing a member of that name at 36. At Name both score 39 and the tie falls to catalog order, which lists every bundle first — so the package would win every such query. Flipping the weight was run once: the assertion goes red with [39, 39]. - SkillsView grouped by provenance, which discards the rank. Under a query it is now one ranked "Matches (n)" list, as BrowseSkillsModal does; browsing keeps the headings. Deletability moved from the group to the row with it, since a Matches list mixes provenances and an extension-supplied skill must still offer no Delete. Rust is untouched: the field weights for a single skill are exactly `search_fields` in agents/skills_extension.rs. --- .../BottomMenuSkillSelection.test.tsx | 67 ++++++++++ .../bottom_menu/BottomMenuSkillSelection.tsx | 32 +++-- .../src/components/skills/SkillsView.test.tsx | 94 +++++++++++++ .../src/components/skills/SkillsView.tsx | 54 ++++---- .../components/skills/searchCatalog.test.ts | 125 ++++++++++++++++++ .../src/components/skills/searchCatalog.ts | 102 ++++++++++++++ 6 files changed, 429 insertions(+), 45 deletions(-) create mode 100644 ui/desktop/src/components/skills/searchCatalog.test.ts create mode 100644 ui/desktop/src/components/skills/searchCatalog.ts diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx index c8712475d..3bc821767 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx @@ -478,3 +478,70 @@ describe('BottomMenuSkillSelection', () => { expect(await screen.findByText(/Could not read the skill catalog/)).toBeInTheDocument(); }); }); + +/** + * QA finding F5, this picker's copy of it — the same defect the Settings list + * carried, and the same fix: `skills/searchCatalog.ts`, which is the BAAM + * matcher over this catalog's fields. + * + * This list is flat, so the ranking applies to it directly: the rows come out + * best match first rather than in the catalog's alphabetical order. + */ +describe('BottomMenuSkillSelection search', () => { + beforeEach(() => { + mocks.overrides.clear(); + vi.clearAllMocks(); + serve(view()); + }); + + const search = (term: string) => + fireEvent.change(screen.getByPlaceholderText('Search skills...'), { target: { value: term } }); + + it('finds the skills a multi-word phrase names, best match first', async () => { + serve(view({ skills: [skill('ggplot'), skill('pdf'), skill('r-scripting')] })); + render(); + await openMenu(); + + search('R scripting ggplot visualization'); + + await waitFor(() => expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2)); + const rows = screen.getAllByRole('menuitemcheckbox'); + // Catalog order is alphabetical — ggplot, pdf, r-scripting — so this is the + // ranking and not the order the rows arrived in. + expect(rows[0]).toHaveTextContent('r-scripting'); + expect(rows[1]).toHaveTextContent('ggplot'); + }); + + it('holds a one-letter query to whole words', async () => { + serve(view({ skills: [skill('markdown-render'), skill('r-scripting')] })); + render(); + await openMenu(); + + search('R'); + + await waitFor(() => expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(1)); + expect(screen.getAllByRole('menuitemcheckbox')[0]).toHaveTextContent('r-scripting'); + }); + + /** + * "Enable all" writes every row the filter left on screen, so a filter that + * returns everything under a one-letter query is a bulk write nobody asked + * for. The count in the button is the filtered count. + */ + it('counts only the matched rows in Enable all', async () => { + serve( + view({ + skills: [ + skill('markdown-render', { state: { ...skill('x').state, effective: false } }), + skill('r-scripting', { state: { ...skill('x').state, effective: false } }), + ], + }) + ); + render(); + await openMenu(); + + search('R'); + + expect(await screen.findByRole('button', { name: 'Enable all (1)' })).toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx index b8f2e3bc5..16779d15e 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx @@ -13,6 +13,7 @@ import { useSkillCatalog, type SkillCatalogEntry, } from '../skills/useSkillCatalog'; +import { rankCatalogEntries } from '../skills/searchCatalog'; import { toastService } from '../../toasts'; import BuiltInBadge from '../ui/BuiltInBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; @@ -82,23 +83,20 @@ export const BottomMenuSkillSelection = ({ sessionId }: BottomMenuSkillSelection [applyToggle, scope] ); - const filteredEntries = useMemo(() => { - const q = searchQuery.toLowerCase(); - if (!q) return entries; - return entries.filter((entry) => { - if (entry.kind === 'single') { - return ( - entry.skill.name.toLowerCase().includes(q) || - entry.skill.description.toLowerCase().includes(q) - ); - } - return ( - entry.bundle.displayName.toLowerCase().includes(q) || - entry.bundle.name.toLowerCase().includes(q) || - entry.bundle.skills.some((name) => name.toLowerCase().includes(q)) - ); - }); - }, [entries, searchQuery]); + // One matcher, shared with Settings -> Skills, the Browse modals and the + // model's own search — see `skills/searchCatalog.ts`. This filter used to ask + // whether the WHOLE query occurred inside one field (QA finding F5), so a + // phrase naming two installed skills found neither. The list is flat, so the + // ranking reaches it directly: best match first, catalog order when nothing + // is typed. + // + // ⚠ It also feeds "Enable all", which writes every row left on screen — so a + // filter that leaks (`R` matching the letter anywhere) is a bulk write over + // rows the user never asked about, not just a long list. + const filteredEntries = useMemo( + () => rankCatalogEntries(entries, searchQuery).hits.map((hit) => hit.entry), + [entries, searchQuery] + ); const activeCount = useMemo(() => entries.filter((e) => e.enabled).length, [entries]); const visibleEnabledCount = useMemo( diff --git a/ui/desktop/src/components/skills/SkillsView.test.tsx b/ui/desktop/src/components/skills/SkillsView.test.tsx index 63109bd4a..a1fdebeff 100644 --- a/ui/desktop/src/components/skills/SkillsView.test.tsx +++ b/ui/desktop/src/components/skills/SkillsView.test.tsx @@ -437,3 +437,97 @@ describe('SkillsView built-in bundles', () => { expect(within(row as HTMLElement).getByLabelText(/Delete skill package/)).toBeInTheDocument(); }); }); + +/** + * QA finding F5, this view's copy of it. + * + * The filter asked whether the WHOLE lowercased query occurred inside one + * field, so a phrase naming two installed skills matched neither of them, and + * a one-letter query matched every skill whose prose contained that letter. + * Both are measured below on the rows the daemon serves; the matcher they now + * go through is `searchCatalog.ts`, which is `baam/search.ts` with this + * catalog's fields. + */ +describe('SkillsView search', () => { + const search = (term: string) => + fireEvent.change(screen.getByLabelText('Search skills'), { target: { value: term } }); + + it('finds the skills a multi-word phrase names, best match first', async () => { + serve({ skills: [skill('ggplot'), skill('pdf'), skill('r-scripting')] }); + render(); + await screen.findByText('ggplot'); + + search('R scripting ggplot visualization'); + + // Both are named by the query; before this change the whole phrase was + // looked for as a substring and neither row survived. + expect(await screen.findByText('r-scripting')).toBeInTheDocument(); + expect(screen.getByText('ggplot')).toBeInTheDocument(); + expect(screen.queryByText('pdf')).not.toBeInTheDocument(); + + // One ranked list under a query, not the provenance groups: `r-scripting` + // matches two of the query's terms and `ggplot` one, and a heading would + // have ordered them alphabetically instead. + const matches = screen.getByRole('heading', { level: 2, name: /Matches \(2\)/ }).parentElement!; + const text = matches.textContent ?? ''; + expect(text.indexOf('r-scripting')).toBeLessThan(text.indexOf('ggplot')); + expect(screen.queryByText(/Biorouter Skills/)).not.toBeInTheDocument(); + }); + + it('holds a one-letter query to whole words', async () => { + serve({ skills: [skill('markdown-render'), skill('r-scripting')] }); + render(); + await screen.findByText('r-scripting'); + + search('R'); + + expect( + await screen.findByRole('heading', { level: 2, name: /Matches \(1\)/ }) + ).toBeInTheDocument(); + expect(screen.getByText('r-scripting')).toBeInTheDocument(); + // `markdown-render` holds the letter twice and means nothing by it. + expect(screen.queryByText('markdown-render')).not.toBeInTheDocument(); + }); + + it('keeps the provenance groups when nothing is typed', async () => { + serve({ + skills: [ + skill('my-skill'), + skill('word', { + sourceRoot: '/extensions/BiorOffice/skills', + source: { kind: 'extension', extension: 'BiorOffice', label: 'BiorOffice' }, + }), + ], + }); + render(); + + expect(await screen.findByText('Biorouter Skills (1)')).toBeInTheDocument(); + expect(screen.getByText('From BiorOffice (1)')).toBeInTheDocument(); + expect(screen.queryByText(/Matches \(/)).not.toBeInTheDocument(); + }); + + /** + * The Delete a row offers follows the ROW's own source, not the heading it + * happens to sit under — which is the thing one flat ranked list could + * quietly lose, since `fromExtension` used to be a property of the group. + */ + it('still offers no Delete for an extension-supplied skill inside the matches list', async () => { + serve({ + skills: [ + skill('r-scripting'), + skill('r-plotting', { + sourceRoot: '/extensions/BiorOffice/skills', + source: { kind: 'extension', extension: 'BiorOffice', label: 'BiorOffice' }, + }), + ], + }); + render(); + await screen.findByText('r-scripting'); + + search('R'); + + expect(await screen.findByText('r-plotting')).toBeInTheDocument(); + expect(screen.getByLabelText('Delete r-scripting')).toBeInTheDocument(); + expect(screen.queryByLabelText('Delete r-plotting')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/skills/SkillsView.tsx b/ui/desktop/src/components/skills/SkillsView.tsx index 874f8e764..4af02f622 100644 --- a/ui/desktop/src/components/skills/SkillsView.tsx +++ b/ui/desktop/src/components/skills/SkillsView.tsx @@ -21,6 +21,7 @@ import { ReadableContent } from '../Layout/ReadableContent'; import { removeSkillPackage } from '../../api'; import type { CatalogBundle, CatalogSkill } from '../../api'; import { skillCatalogToggleKey, useSkillCatalog, type SkillCatalogEntry } from './useSkillCatalog'; +import { isBrowseQuery, rankCatalogEntries } from './searchCatalog'; /** * Settings → Skills. @@ -40,8 +41,6 @@ type Group = { key: string; title: string; entries: SkillCatalogEntry[]; - /** Skills an installed extension supplies. Not the user's to delete. */ - fromExtension: boolean; }; export default function SkillsView() { @@ -70,23 +69,20 @@ export default function SkillsView() { ); const groups = useMemo((): Group[] => { - const match = (entry: SkillCatalogEntry) => { - if (!searchTerm) return true; - const q = searchTerm.toLowerCase(); - if (entry.kind === 'single') { - return ( - entry.skill.name.toLowerCase().includes(q) || - entry.skill.description.toLowerCase().includes(q) - ); - } - return ( - entry.bundle.displayName.toLowerCase().includes(q) || - entry.bundle.name.toLowerCase().includes(q) || - entry.bundle.skills.some((name) => name.toLowerCase().includes(q)) - ); - }; + // One matcher, shared with the composer's picker, the Browse modals and the + // model's own search — see `searchCatalog.ts`. The filter here used to ask + // whether the WHOLE query occurred inside one field (QA finding F5). + const visible = rankCatalogEntries(entries, searchTerm).hits.map((hit) => hit.entry); + + // ⚠ **A search is one ranked list, not the provenance headings.** The + // headings are a grouping, and a grouping discards the rank: a Biorouter + // skill matching one word of the query would sit above a project skill + // matching all of them. `BrowseSkillsModal` resolved the same tension the + // same way, down to the "Matches (n)" heading. + if (!isBrowseQuery(searchTerm)) { + return visible.length > 0 ? [{ key: 'matches', title: 'Matches', entries: visible }] : []; + } - const visible = entries.filter(match); const biorouter = visible.filter((e) => sourceOf(e).kind === 'biorouter'); const project = visible.filter((e) => sourceOf(e).kind === 'project'); const other = visible.filter((e) => ['claudeHome', 'agentsHome'].includes(sourceOf(e).kind)); @@ -107,14 +103,12 @@ export default function SkillsView() { key: 'biorouter', title: 'Biorouter Skills', entries: biorouter, - fromExtension: false, }); for (const [extension, extensionEntries] of [...byExtension].sort()) { out.push({ key: `extension:${extension}`, title: `From ${extension}`, entries: extensionEntries, - fromExtension: true, }); } if (other.length) @@ -122,14 +116,12 @@ export default function SkillsView() { key: 'other', title: 'Skills From Other Agents', entries: other, - fromExtension: false, }); if (project.length) out.push({ key: 'project', title: 'From This Project', entries: project, - fromExtension: false, }); return out; }, [entries, searchTerm]); @@ -259,8 +251,14 @@ export default function SkillsView() {
- {group.entries.map((entry) => - entry.kind === 'bundle' ? ( + {group.entries.map((entry) => { + // ⚠ Per ENTRY, not per group. A skill an installed + // extension supplies is not the user's to delete — the + // extension would put it back — and under a query every + // provenance is in one "Matches" list, so a flag on the + // group would offer Delete on rows that must not have it. + const fromExtension = sourceOf(entry).kind === 'extension'; + return entry.kind === 'bundle' ? ( void window.electron.openDirectoryInExplorer(entry.bundle.directory) } - onDelete={group.fromExtension ? undefined : () => setPendingDelete(entry)} + onDelete={fromExtension ? undefined : () => setPendingDelete(entry)} onToggle={(enabled) => void toggle(entry, enabled)} /> ) : ( @@ -289,12 +287,12 @@ export default function SkillsView() { onClick={() => void window.electron.openDirectoryInExplorer(entry.skill.directory) } - onDelete={group.fromExtension ? undefined : () => setPendingDelete(entry)} + onDelete={fromExtension ? undefined : () => setPendingDelete(entry)} onShare={() => void copySkill(entry.skill)} onToggle={(enabled) => void toggle(entry, enabled)} /> - ) - )} + ); + })}
))} diff --git a/ui/desktop/src/components/skills/searchCatalog.test.ts b/ui/desktop/src/components/skills/searchCatalog.test.ts new file mode 100644 index 000000000..b10196892 --- /dev/null +++ b/ui/desktop/src/components/skills/searchCatalog.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import type { CatalogBundle, CatalogSkill } from '../../api'; +import type { SkillCatalogEntry } from './useSkillCatalog'; +import { catalogSearchFields, rankCatalogEntries } from './searchCatalog'; +import { Weight } from '../baam/search'; + +const state = { + machineEnabled: true, + session: 'default' as const, + sessionViaBundle: false, + hiddenContext: false, + effective: true, +}; + +function single(name: string, description = `${name} does things`): SkillCatalogEntry { + const skill: CatalogSkill = { + name, + description, + slug: name, + directory: `/skills/${name}`, + sourceRoot: '/skills', + source: { kind: 'biorouter', extension: null, label: 'Biorouter' }, + bundle: null, + builtin: false, + state, + }; + return { kind: 'single', key: name, skill, enabled: true }; +} + +function pack(name: string, members: string[], displayName = name): SkillCatalogEntry { + const bundle: CatalogBundle = { + name, + displayName, + directory: `/skills/${name}`, + sourceRoot: '/skills', + source: { kind: 'biorouter', extension: null, label: 'Biorouter' }, + skills: members, + package: null, + builtin: false, + state, + }; + return { kind: 'bundle', key: name, bundle, enabled: true }; +} + +const names = (entries: readonly SkillCatalogEntry[], query: string) => + rankCatalogEntries(entries, query).hits.map((hit) => + hit.entry.kind === 'single' ? hit.entry.skill.name : hit.entry.bundle.displayName + ); + +describe('installed-catalog search fields', () => { + /** + * A single row's fields are the Rust ones, so the picker and + * `skills__searchSkills` read the same text at the same weights + * (`search_fields` in `agents/skills_extension.rs`). + */ + it('weights a single skill the way the model-facing search does', () => { + expect(catalogSearchFields(single('ggplot', 'plots'))).toEqual([ + ['ggplot', Weight.Name], + ['plots', Weight.Prose], + [undefined, Weight.Label], + ]); + }); + + /** + * ⚠ The decision this file exists to record: a member name is a LABEL on the + * row that contains it, not that row's name. See the note on `searchCatalog.ts`. + */ + it('reads a bundle row as its own names plus its members as labels', () => { + expect(catalogSearchFields(pack('tidyverse', ['ggplot', 'dplyr'], 'Tidyverse'))).toEqual([ + ['Tidyverse', Weight.Name], + ['tidyverse', Weight.Name], + ['ggplot', Weight.Label], + ['dplyr', Weight.Label], + ]); + }); +}); + +describe('installed-catalog search', () => { + /** + * QA finding F5. The whole phrase is in no single field, so the filter this + * replaces returned nothing; a query is a union of its words, ranked. + */ + it('finds every skill a multi-word phrase names, best match first', () => { + const entries = [single('ggplot'), single('pdf'), single('r-scripting')]; + expect(names(entries, 'R scripting ggplot visualization')).toEqual(['r-scripting', 'ggplot']); + }); + + it('holds a one-letter query to whole words', () => { + const entries = [single('markdown-render'), single('r-scripting')]; + expect(names(entries, 'R')).toEqual(['r-scripting']); + }); + + it('finds a package by a skill it contains', () => { + const entries = [single('pdf'), pack('tidyverse', ['ggplot', 'dplyr'], 'Tidyverse')]; + expect(names(entries, 'ggplot')).toEqual(['Tidyverse']); + }); + + /** + * ⚠ **What makes {@link Weight.Label} the right weight for a member name, and + * the assertion that fails if it is changed to `Name`.** + * + * A skill called `ggplot` and a package that merely contains one both hold the + * query as written and both match its only term, so the two are separated by + * the field weight alone: 3 (a whole-word match) × 3 (`Name`) = 9 against + * 3 × 2 (`Label`) = 6. The scores are asserted, not just the order, because at + * `Name` the two would TIE at 39 — and a tie keeps catalog order, which lists + * every bundle before every single skill, so the package would silently win + * every such query. + */ + it('ranks a skill above a package that merely contains one by that name', () => { + const skill = single('ggplot'); + const bundle = pack('tidyverse', ['ggplot', 'dplyr'], 'Tidyverse'); + // Catalog order, which `useSkillCatalog` builds bundles-first. + const hits = rankCatalogEntries([bundle, skill], 'ggplot').hits; + + expect(hits.map((hit) => hit.score)).toEqual([39, 36]); + expect(hits[0].entry).toBe(skill); + expect(hits[1].entry).toBe(bundle); + }); + + it('returns every row in catalog order when nothing is typed', () => { + const entries = [pack('tidyverse', ['ggplot']), single('pdf')]; + expect(names(entries, ' ')).toEqual(['tidyverse', 'pdf']); + }); +}); diff --git a/ui/desktop/src/components/skills/searchCatalog.ts b/ui/desktop/src/components/skills/searchCatalog.ts new file mode 100644 index 000000000..c6c9c2277 --- /dev/null +++ b/ui/desktop/src/components/skills/searchCatalog.ts @@ -0,0 +1,102 @@ +/** + * Free-text search over the INSTALLED skill catalog — the matcher behind + * Settings → Skills and the composer's skill picker. + * + * It is not a matcher. The rules all live in `baam/search.ts`, the port of + * `crates/biorouter/src/catalog_search.rs`, and this file says only what text of + * an installed row is searched and what a match there is worth. Both surfaces + * used to carry their own copy of a whole-phrase `includes(query)` test — QA + * finding F5's third and fourth copies, after the Rust catalog search (PR #266) + * and the Browse modals (PR #255). Measured on this tree against the rows the + * two pickers render, before the change: `R scripting ggplot visualization` + * listed **0 of 3** rows on both surfaces while `ggplot` alone listed 1, and + * `R` listed **2 of 2** — `markdown-render`, which holds the letter twice and + * means nothing by it, and `r-scripting`. + * + * ⚠ **`baam/search.ts` stays where it is, and this file does not copy it.** The + * matcher is shared by import; only the name of its directory is now narrower + * than its callers. Moving it would rewrite four BAAM files for a rename, and + * the header that has to stay accurate — the one warning that a rule change is + * a change to three files, `landing/marketplace-search.js` included — is that + * file's, not this one's. + * + * # The two decisions this file had to make + * + * **1. A bundle row has no counterpart in Rust.** `skills__searchSkills` ranks + * individual skills (`search_fields` in `agents/skills_extension.rs`): the + * skill's own name at {@link Weight.Name}, its description at + * {@link Weight.Prose}, the bundle it ships in at {@link Weight.Label}. No row + * stands for a whole package there, so a member's name is the `Name` field of a + * different entry. In these two pickers the package IS a row — the member is + * reached through it, and in the composer only the package can be toggled — so + * member names have to be searchable somewhere, or a package is unfindable by + * what it contains. + * + * They are searched at {@link Weight.Label}, which is a TypeScript-only rule and + * is argued rather than ported: on a bundle row a member's name is not what the + * row is called, it is one of the labels saying what the row contains — the same + * role a tag plays on a marketplace card. The weight is load-bearing, and + * `searchCatalog.test.ts` pins it with the scores measured on this tree: for the + * query `ggplot`, a skill of its own by that name scores 39 and a package merely + * containing a member called `ggplot` scores 36, so the skill itself ranks + * first. At `Name` both score 39 and the tie falls to catalog order — which puts + * every bundle above every single skill, so the package would always win. + * + * **2. `SkillsView` groups by provenance and would throw the ranking away.** It + * renders Biorouter / per-extension / other-agent / project headings, so under a + * query a Biorouter skill matching one word would sit above a project skill + * matching all of them. It now does what `BrowseSkillsModal` does — one ranked + * "Matches (n)" list under a query, the headings when browsing — and reads + * `isBrowseQuery` from the same module to decide which. The composer's list is + * flat, so the ranking reaches it directly. + */ + +import { + rankEntries, + SKILL_NOISE, + Weight, + type SearchField, + type SearchResult, +} from '../baam/search'; +import type { SkillCatalogEntry } from './useSkillCatalog'; + +export { isBrowseQuery } from '../baam/search'; + +/** + * What one installed row is searched over. + * + * For a single skill these are exactly the Rust fields, so a user typing here + * and a model calling `skills__searchSkills` read the same text: the name, the + * description, and the bundle when the row is a member of one. (`slug` is + * searched on neither side, and was not searched by the filter this replaces.) + * + * For a bundle row, see the note on this file about member names. + */ +export function catalogSearchFields(entry: SkillCatalogEntry): SearchField[] { + if (entry.kind === 'single') { + return [ + [entry.skill.name, Weight.Name], + [entry.skill.description, Weight.Prose], + // Total over the absent case rather than conditional: a picker row is a + // standalone skill today, but the field is the Rust one and stays. + [entry.skill.bundle ?? undefined, Weight.Label], + ]; + } + return [ + [entry.bundle.displayName, Weight.Name], + [entry.bundle.name, Weight.Name], + ...entry.bundle.skills.map((member): SearchField => [member, Weight.Label]), + ]; +} + +/** + * Rank installed catalog rows against what the user typed, best first. An empty + * query returns every row in catalog order, which is what both surfaces showed + * before a character was typed. + */ +export function rankCatalogEntries( + entries: readonly SkillCatalogEntry[], + query: string +): SearchResult { + return rankEntries(query, SKILL_NOISE, entries, catalogSearchFields); +}