mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): harden resources picker search
This commit is contained in:
parent
8ae8cae5cf
commit
108fabb839
2 changed files with 145 additions and 64 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue