fix(web): harden resources picker search

This commit is contained in:
Aria 2026-05-31 04:52:17 +02:00
parent 8ae8cae5cf
commit 108fabb839
2 changed files with 145 additions and 64 deletions

View file

@ -2794,7 +2794,7 @@ function ToolsPluginsPanel({
);
const scopedPlugins = source === 'community' ? communityPlugins : userPlugins;
const visiblePlugins = useMemo(
() => scopedPlugins.filter((p) => pluginMatchesQuery(p, query)),
() => rankMentionItems(scopedPlugins, query, pluginMentionScore),
[scopedPlugins, query],
);
const activeResourceIndex = resourceActiveIndex(activeIndex, visiblePlugins.length);
@ -2820,7 +2820,10 @@ function ToolsPluginsPanel({
role="tab"
aria-selected={source === 'community'}
className={`composer-tools-segment${source === 'community' ? ' active' : ''}`}
onClick={() => setSource('community')}
onClick={() => {
setSource('community');
onActiveIndexChange(0);
}}
title={`${communityPlugins.length} installed official plugins`}
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
activeIndex: activeResourceIndex,
@ -2836,7 +2839,10 @@ function ToolsPluginsPanel({
role="tab"
aria-selected={source === 'mine'}
className={`composer-tools-segment${source === 'mine' ? ' active' : ''}`}
onClick={() => setSource('mine')}
onClick={() => {
setSource('mine');
onActiveIndexChange(0);
}}
title={`${userPlugins.length} installed user plugins`}
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
activeIndex: activeResourceIndex,
@ -2852,7 +2858,10 @@ function ToolsPluginsPanel({
ref={searchRef}
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
onChange={(e) => {
setQuery(e.currentTarget.value);
onActiveIndexChange(0);
}}
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
activeIndex: activeResourceIndex,
itemCount: visiblePlugins.length,
@ -2989,11 +2998,11 @@ function ToolsMcpPanel({
}) {
const [query, setQuery] = useState('');
const visibleServers = useMemo(
() => servers.filter((s) => mcpServerMatchesQuery(s, query)),
() => rankMentionItems(servers, query, mcpServerMentionScore),
[servers, query],
);
const visibleTemplates = useMemo(
() => templates.filter((tpl) => mcpTemplateMatchesQuery(tpl, query)).slice(0, 8),
() => rankMentionItems(templates, query, mcpTemplateMentionScore).slice(0, 8),
[templates, query],
);
const itemCount = visibleServers.length + visibleTemplates.length + 1;
@ -3021,7 +3030,10 @@ function ToolsMcpPanel({
ref={searchRef}
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
onChange={(e) => {
setQuery(e.currentTarget.value);
onActiveIndexChange(0);
}}
onKeyDown={keyboard}
placeholder="Search MCP…"
aria-label="Search MCP servers and templates"
@ -3031,14 +3043,9 @@ function ToolsMcpPanel({
}
/>
</div>
{visibleServers.length === 0 ? (
<div className="composer-tools-empty">
{servers.length === 0
? 'No enabled MCP servers configured yet.'
: `No configured MCP results for “${query}”.`}
</div>
) : (
<div className="composer-tools-list" id="composer-tools-mcp-results">
<div className="composer-tools-list" id="composer-tools-mcp-results">
{visibleServers.length > 0 ? (
<>
<div className="composer-tools-section-label">Configured</div>
{visibleServers.map((s) => {
const index = itemIndex;
@ -3070,10 +3077,10 @@ function ToolsMcpPanel({
</button>
);
})}
</div>
)}
{visibleTemplates.length > 0 ? (
<div className="composer-tools-list">
</>
) : null}
{visibleTemplates.length > 0 ? (
<>
<div className="composer-tools-section-label">Templates</div>
{visibleTemplates.map((tpl) => {
const index = itemIndex;
@ -3104,8 +3111,15 @@ function ToolsMcpPanel({
</button>
);
})}
</div>
) : null}
</>
) : null}
{visibleServers.length === 0 && visibleTemplates.length === 0 ? (
<div className="composer-tools-empty">
{servers.length === 0 && templates.length === 0
? 'No enabled MCP servers configured yet.'
: `No MCP results for “${query}”.`}
</div>
) : null}
<button
id={resourceOptionDomId('mcp', itemIndex)}
type="button"
@ -3120,6 +3134,7 @@ function ToolsMcpPanel({
<Icon name="settings" size={12} />
<span>Manage MCP servers</span>
</button>
</div>
</>
);
}
@ -3143,7 +3158,7 @@ function ToolsSkillsPanel({
const [query, setQuery] = useState('');
const [pendingId, setPendingId] = useState<string | null>(null);
const visibleSkills = useMemo(
() => skills.filter((s) => skillMatchesQuery(s, query)).slice(0, 24),
() => rankMentionItems(skills, query, skillMentionScore).slice(0, 24),
[skills, query],
);
const activeResourceIndex = resourceActiveIndex(activeIndex, visibleSkills.length);
@ -3172,7 +3187,10 @@ function ToolsSkillsPanel({
ref={searchRef}
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
onChange={(e) => {
setQuery(e.currentTarget.value);
onActiveIndexChange(0);
}}
onKeyDown={keyboard}
placeholder="Search skills…"
aria-label="Search skills"
@ -3238,10 +3256,6 @@ function ToolsSkillsPanel({
);
}
function pluginMatchesQuery(plugin: InstalledPluginRecord, query: string): boolean {
return pluginMentionScore(plugin, query) !== null;
}
function pluginHasDetails(plugin: InstalledPluginRecord): boolean {
const manifest = plugin.manifest;
const od = manifest?.od as Record<string, unknown> | undefined;
@ -3255,10 +3269,6 @@ function pluginHasDetails(plugin: InstalledPluginRecord): boolean {
);
}
function skillMatchesQuery(skill: SkillSummary, query: string): boolean {
return skillMentionScore(skill, query) !== null;
}
function rankMentionItems<T>(
items: T[],
query: string,
@ -3423,36 +3433,18 @@ function handleResourceKeyboardEvent(
}
}
function mcpServerMatchesQuery(server: McpServerConfig, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
server.id,
server.label ?? '',
server.transport,
server.url ?? '',
server.command ?? '',
]
.join(' ')
.toLowerCase()
.includes(q);
}
function mcpTemplateMatchesQuery(tpl: McpTemplate, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
tpl.id,
tpl.label,
tpl.description,
tpl.transport,
tpl.category,
tpl.homepage ?? '',
tpl.example ?? '',
]
.join(' ')
.toLowerCase()
.includes(q);
function mcpTemplateMentionScore(tpl: McpTemplate, query: string): number | null {
return bestMentionTextScore(query, [
{ value: tpl.label, base: 0 },
{ value: tpl.id, base: 0 },
{ value: tpl.category, base: 3 },
{ value: tpl.transport, base: 4 },
{ value: tpl.command, base: 6 },
{ value: tpl.url, base: 6 },
{ value: tpl.description, base: 8 },
{ value: tpl.homepage, base: 10 },
{ value: tpl.example, base: 10 },
]);
}
function pluginSourceLabel(plugin: InstalledPluginRecord, t: TranslateFn): string {
@ -3480,7 +3472,9 @@ function ToolsImportPanel({
{ icon: 'sparkles' as const, label: t('chat.importSkills') },
{ icon: 'file' as const, label: t('chat.importProject') },
];
const visibleItems = items.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
const visibleItems = rankMentionItems(items, query, (item, q) => bestMentionTextScore(q, [
{ value: item.label, base: 0 },
]));
const activeResourceIndex = resourceActiveIndex(activeIndex, visibleItems.length);
const pickActiveImport = () => {
const item = activeResourceIndex >= 0 ? visibleItems[activeResourceIndex] : null;
@ -3500,7 +3494,10 @@ function ToolsImportPanel({
ref={searchRef}
className="composer-tools-search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
onChange={(event) => {
setQuery(event.currentTarget.value);
onActiveIndexChange(0);
}}
onKeyDown={keyboard}
placeholder="Search imports…"
aria-label="Search imports"

View file

@ -98,6 +98,15 @@ const MCP_SERVER = {
command: 'slack-mcp',
};
const MCP_TEMPLATE = {
id: 'figma-context',
label: 'Figma Context',
description: 'Read design frames from Figma files.',
transport: 'stdio' as const,
category: 'design-systems' as const,
command: 'figma-mcp',
};
const APPLY_RESULT = {
ok: true,
query: 'Run plugin.',
@ -132,6 +141,7 @@ let fetchMock: ReturnType<typeof vi.fn>;
let plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
let skills = [SKILL];
let servers = [MCP_SERVER];
let templates = [MCP_TEMPLATE];
function renderComposer(
overrides: Partial<ComponentProps<typeof ChatComposer>> = {},
@ -168,9 +178,10 @@ beforeEach(() => {
plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
skills = [SKILL];
servers = [MCP_SERVER];
templates = [MCP_TEMPLATE];
fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers, templates: [] }), {
return new Response(JSON.stringify({ servers, templates }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
@ -699,6 +710,79 @@ describe('ChatComposer context pickers', () => {
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
});
it('ranks Resources search results and resets the active option as the query changes', async () => {
plugins = [
makePlugin({
id: 'stone-staircase',
title: '3D Stone Staircase Evolution Infographic',
description: 'Transforms a flat evolutionary timeline into realistic stone stairs.',
}),
makePlugin({
id: 'airbnb',
title: 'Airbnb',
description: 'Travel marketplace with warm coral UI.',
}),
makePlugin({
id: 'airtable',
title: 'Airtable',
description: 'Spreadsheet-database hybrid.',
}),
];
renderComposer();
fireEvent.click(screen.getByLabelText('Open resources menu'));
const search = await screen.findByLabelText('Search plugins');
fireEvent.change(search, { target: { value: 'air' } });
await waitFor(() => expect(screen.getByText('Airtable')).toBeTruthy());
const menu = screen.getByRole('menu');
const pluginNames = within(menu)
.getAllByRole('menuitem')
.map((item) => item.querySelector('strong')?.textContent)
.filter(Boolean);
expect(pluginNames.slice(0, 3)).toEqual([
'Airbnb',
'Airtable',
'3D Stone Staircase Evolution Infographic',
]);
fireEvent.keyDown(search, { key: 'ArrowDown' });
expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-1');
fireEvent.change(search, { target: { value: 'stone' } });
expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-0');
expect(
screen.getByText('3D Stone Staircase Evolution Infographic').closest('[role="menuitem"]')?.getAttribute('aria-selected'),
).toBe('true');
});
it('keeps MCP Resources search wired to a stable result container for templates and empty results', async () => {
servers = [];
templates = [MCP_TEMPLATE];
const onOpenMcpSettings = vi.fn();
renderComposer({ onOpenMcpSettings });
fireEvent.click(screen.getByLabelText('Open resources menu'));
fireEvent.click(await screen.findByRole('tab', { name: 'MCP' }));
const search = await screen.findByLabelText('Search MCP servers and templates');
await waitFor(() => expect(document.activeElement).toBe(search));
expect(document.getElementById('composer-tools-mcp-results')).toBeTruthy();
expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-mcp-option-0');
fireEvent.change(search, { target: { value: 'figma' } });
expect(screen.getByText('Figma Context')).toBeTruthy();
expect(document.getElementById(search.getAttribute('aria-activedescendant') ?? '')).toBeTruthy();
fireEvent.change(search, { target: { value: 'definitely-missing' } });
expect(screen.getByText('No MCP results for “definitely-missing”.')).toBeTruthy();
expect(document.getElementById(search.getAttribute('aria-activedescendant') ?? '')).toBeTruthy();
fireEvent.keyDown(search, { key: 'Enter' });
expect(onOpenMcpSettings).toHaveBeenCalledTimes(1);
});
it('clears absolute anchors when the pet popover switches to fixed positioning', async () => {
renderComposer({
petConfig: {