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
2 changes: 2 additions & 0 deletions docs/extensions/extensions-and-skills-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Marketplace installs never ask you for a file. **Extensions** → **Browse Exten

The **Add Extension** button beside it is the local route, and that one does take a file: drag a `.brxt` onto it, or browse for one.

Both marketplace browsers — **Browse Extensions**, and **Browse Skills** on the **Skills** page — search word by word: a phrase such as `R scripting ggplot visualization` lists every entry that matches any of its words, best match first, ranked the way the agent's own marketplace search ranks them. Clear the search box to browse the whole catalog again.

If an extension needs an API key, passcode or token, Biorouter asks for it in its own dialog and stores it in your operating system's credential store. **Never type a credential into the chat** — it cannot configure anything from there, and it would be visible to every model that reads the conversation. The same is true of the command line: `biorouter extension install` prompts with echo off rather than taking a value as an argument. See [Installing an extension, and where its credentials go](installing-an-extension.md).

### Developing a custom extension
Expand Down
41 changes: 38 additions & 3 deletions ui/desktop/src/components/baam/BrowseExtensionsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import BrowseExtensionsModal from './BrowseExtensionsModal';
import { MARKETPLACE_EXTENSIONS } from './marketplace.fixture';
import type { BrxtEnvVar, BrxtManifest } from '../../types/brxt';

/**
Expand All @@ -20,12 +21,12 @@ const loadRegistry = vi.hoisted(() => vi.fn());
// Spread the real module rather than listing members: a partial factory means
// every export this component newly reaches for (`effectivePrivacy`,
// `catalogFreshnessLine`) arrives `undefined` and the modal dies at render, in a
// test that has nothing to say about either. Only the two seams the test
// actually controls are replaced.
// test that has nothing to say about either. Only the seam the test actually
// controls is replaced; the search is the real one, and no test here types a
// query, so every entry is listed.
vi.mock('./registry', async (importOriginal) => ({
...(await importOriginal<typeof import('./registry')>()),
loadRegistry,
extensionMatches: () => true,
}));

vi.mock('../ConfigContext', () => ({
Expand Down Expand Up @@ -353,3 +354,37 @@ describe('BrowseExtensionsModal — installed rows (issue #116)', () => {
expect(screen.queryByRole('button', { name: 'Configure' })).toBeNull();
});
});

/** The extension names the list shows, top to bottom. */
function shownExtensionNames(): string[] {
return Array.from(document.querySelectorAll('div.biorouter-modal-row')).map(
(row) => row.querySelector('span')?.textContent ?? ''
);
}

/// Finding F5 in the extensions catalog: the phrase occurs in no field
/// verbatim, so the whole-phrase matcher this replaced listed nothing for it.
/// The ranking itself is pinned in `search.test.ts`; this pins that the list
/// on screen is the ranked one.
describe('BrowseExtensionsModal — a multi-word search (finding F5)', () => {
it('lists what the phrase matches, best match first', async () => {
loadRegistry.mockResolvedValue({
live: true,
registry: { extensions: MARKETPLACE_EXTENSIONS, skills: [] },
});
const user = userEvent.setup();
renderModal();

await screen.findByText('SPOKEAgent');
expect(shownExtensionNames()).toEqual([
'CDWAgent',
'SPOKEAgent',
'CodeGraph Agent',
'PrimeKGAgent',
]);

await user.type(screen.getByPlaceholderText(/Search extensions/), 'SPOKE knowledge graph');

expect(shownExtensionNames()).toEqual(['SPOKEAgent', 'PrimeKGAgent', 'CodeGraph Agent']);
});
});
5 changes: 3 additions & 2 deletions ui/desktop/src/components/baam/BrowseExtensionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Package } from '../icons/app-icons';
import { BrxtInstallModal } from '../BrxtInstallModal';
import {
loadRegistry,
extensionMatches,
rankExtensions,
effectivePrivacy,
catalogFreshnessLine,
type BaamRegistry,
Expand Down Expand Up @@ -73,9 +73,10 @@ export default function BrowseExtensionsModal({
const isInstalled = (e: RegistryExtension) =>
installedNames.has(e.name.toLowerCase()) || installedNames.has(e.id.toLowerCase());

/** Best match first under a query; registry order when there is none. */
const filtered = useMemo(() => {
if (!registry) return [];
return registry.extensions.filter((e) => extensionMatches(e, search));
return rankExtensions(registry.extensions, search).hits.map((hit) => hit.entry);
}, [registry, search]);

/**
Expand Down
118 changes: 118 additions & 0 deletions ui/desktop/src/components/baam/BrowseSkillsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MARKETPLACE_SKILLS } from './marketplace.fixture';

const mocks = vi.hoisted(() => ({ loadRegistry: vi.fn() }));

Expand Down Expand Up @@ -93,3 +95,119 @@ describe('BrowseSkillsModal — the install label has one space between words',
expect(button.textContent).toBe('Install skills');
});
});

/** The skill names the list shows, top to bottom: one checkbox per row. */
function shownSkillNames(): string[] {
return screen
.queryAllByRole('checkbox')
.map((box) => box.closest('label')?.querySelector('span')?.textContent ?? '');
}

async function openWithMarketplaceSkills() {
mocks.loadRegistry.mockResolvedValue({
registry: { version: 2, source: 'test', extensions: [], skills: MARKETPLACE_SKILLS },
live: true,
fetchedAt: '2026-09-10T00:00:00Z',
});
const user = userEvent.setup();
render(<BrowseSkillsModal onClose={vi.fn()} onInstalled={vi.fn()} installedIds={new Set()} />);
await screen.findByText('R Scripting');
return { user, searchBox: screen.getByPlaceholderText(/Search skills/) };
}

/// Finding F5, in the desktop modal. The model-facing search (#242) and this one
/// were the same defect in two languages: the WHOLE query had to occur inside a
/// single field, so every word of `R scripting ggplot visualization` finds a
/// skill on its own and the phrase found none.
describe('BrowseSkillsModal — a multi-word search (finding F5)', () => {
it('shows the union of what each word finds, best match first', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, 'R scripting ggplot visualization');

expect(shownSkillNames()).toEqual([
'ggplot2 Visualization',
'R Scripting',
'Data Visualization',
'Python Scripting',
'Clinical Biostatistics',
]);
});

/// The two single-word controls the QA run measured beside the phrase.
it('finds exactly the two ggplot skills for `ggplot`', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, 'ggplot');

expect(shownSkillNames()).toEqual(['ggplot2 Visualization', 'Data Visualization']);
});

it('puts the skill a query names first', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, 'r-scripting');

expect(shownSkillNames()[0]).toBe('R Scripting');
});
});

/** The section headings above the list, top to bottom. */
function shownHeadings(): string[] {
return screen.queryAllByRole('heading', { level: 3 }).map((heading) => heading.textContent ?? '');
}

describe('BrowseSkillsModal — browsing is grouped, a search is ranked', () => {
it('groups the catalog under its category headings, in registry order', async () => {
await openWithMarketplaceSkills();

expect(shownHeadings()).toEqual(['Core skills (4)', 'Biomedical analysis (3)']);
expect(shownSkillNames()).toEqual([
'Scientific Visual Communication',
'ggplot2 Visualization',
'R Scripting',
'Python Scripting',
'Clinical Biostatistics',
'Data Visualization',
'Single-cell',
]);
});

/// Under the category headings, Python Scripting (one term matched) would sit
/// above Data Visualization (two) only because Core is listed before
/// Biomedical — the ranking would be computed and then not shown.
it('shows a search as one list in rank order', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, 'R scripting ggplot visualization');

expect(shownHeadings()).toEqual(['Matches (5)']);
});

it('treats a query of only spaces as browsing', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, ' ');

expect(shownHeadings()).toEqual(['Core skills (4)', 'Biomedical analysis (3)']);
});

it('keeps the category filter under a search', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.click(screen.getByRole('button', { name: 'Biomedical analysis' }));
await user.type(searchBox, 'R scripting ggplot visualization');

expect(shownSkillNames()).toEqual(['Data Visualization', 'Clinical Biostatistics']);
});

it('says so when nothing matches', async () => {
const { user, searchBox } = await openWithMarketplaceSkills();

await user.type(searchBox, 'xylophone');

expect(shownSkillNames()).toEqual([]);
expect(shownHeadings()).toEqual([]);
expect(screen.getByText('No skills match your search.')).toBeInTheDocument();
});
});
38 changes: 23 additions & 15 deletions ui/desktop/src/components/baam/BrowseSkillsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { Button } from '../ui/button';
import { toastSuccess, toastError } from '../../toasts';
import {
loadRegistry,
skillMatches,
rankSkills,
catalogFreshnessLine,
type BaamRegistry,
type RegistrySkill,
type SkillCategory,
} from './registry';
import { isBrowseQuery } from './search';
import { installRegistrySkill } from './installSkill';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';

Expand Down Expand Up @@ -76,19 +77,28 @@ export default function BrowseSkillsModal({ onClose, onInstalled, installedIds }
const isInstalled = (s: RegistrySkill) =>
installedIds.has(s.id.toLowerCase()) || installedIds.has(s.name.toLowerCase());

/** Best match first under a query; registry order when there is none. */
const filtered = useMemo(() => {
if (!registry) return [];
return registry.skills.filter(
(s) => (filter === 'All' || s.category === filter) && skillMatches(s, search)
);
const inCategory = registry.skills.filter((s) => filter === 'All' || s.category === filter);
return rankSkills(inCategory, search).hits.map((hit) => hit.entry);
}, [registry, filter, search]);

const grouped = useMemo(() => {
const map = new Map<SkillCategory, RegistrySkill[]>();
for (const cat of CATEGORY_ORDER) map.set(cat, []);
for (const s of filtered) map.get(s.category)?.push(s);
return map;
}, [filtered]);
/**
* Browsing groups the catalog under its category headings. A search is one
* list in rank order instead: under the headings, a Core skill matching one
* word of the query would sit above a Biomedical skill matching all of them.
*/
const sections = useMemo(() => {
if (!isBrowseQuery(search)) {
return filtered.length > 0 ? [{ key: 'matches', label: 'Matches', items: filtered }] : [];
}
return CATEGORY_ORDER.map((cat) => ({
key: cat,
label: CATEGORY_LABELS[cat],
items: filtered.filter((s) => s.category === cat),
})).filter((section) => section.items.length > 0);
}, [filtered, search]);

const selectableFiltered = filtered.filter((s) => !isInstalled(s));
const allFilteredSelected =
Expand Down Expand Up @@ -237,13 +247,11 @@ export default function BrowseSkillsModal({ onClose, onInstalled, installedIds }
</p>
)}
{registry &&
CATEGORY_ORDER.map((cat) => {
const items = grouped.get(cat) ?? [];
if (items.length === 0) return null;
sections.map(({ key, label, items }) => {
return (
<div key={cat} className="mb-4">
<div key={key} className="mb-4">
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{CATEGORY_LABELS[cat]} ({items.length})
{label} ({items.length})
</h3>
<div className="flex flex-col gap-1.5">
{items.map((skill) => {
Expand Down
Loading
Loading