Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<BottomMenuSkillSelection sessionId={null} />);
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(<BottomMenuSkillSelection sessionId={null} />);
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(<BottomMenuSkillSelection sessionId={null} />);
await openMenu();

search('R');

expect(await screen.findByRole('button', { name: 'Enable all (1)' })).toBeInTheDocument();
});
});
32 changes: 15 additions & 17 deletions ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
94 changes: 94 additions & 0 deletions ui/desktop/src/components/skills/SkillsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);

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(<SkillsView />);
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();
});
});
54 changes: 26 additions & 28 deletions ui/desktop/src/components/skills/SkillsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() {
Expand Down Expand Up @@ -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));
Expand All @@ -107,29 +103,25 @@ 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)
out.push({
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]);
Expand Down Expand Up @@ -259,8 +251,14 @@ export default function SkillsView() {
</span>
</h2>
<div className="biorouter-list-shell">
{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' ? (
<BundleRow
key={entry.key}
bundle={entry.bundle}
Expand All @@ -278,7 +276,7 @@ export default function SkillsView() {
onOpen={() =>
void window.electron.openDirectoryInExplorer(entry.bundle.directory)
}
onDelete={group.fromExtension ? undefined : () => setPendingDelete(entry)}
onDelete={fromExtension ? undefined : () => setPendingDelete(entry)}
onToggle={(enabled) => void toggle(entry, enabled)}
/>
) : (
Expand All @@ -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)}
/>
)
)}
);
})}
</div>
</div>
))}
Expand Down
Loading
Loading