fix: search agents by name or description using fuzzy matching

This commit is contained in:
Bohdan Triapitsyn
2026-02-12 00:26:55 +02:00
parent 0e370ba1f2
commit bc41f727fa
2 changed files with 50 additions and 20 deletions
@@ -441,8 +441,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return sorted; return sorted;
} }
return sorted.filter((agent) => return sorted.filter((agent) =>
fuzzyMatch(agentSearchQuery, agent.name) || fuzzyMatch(agent.name, agentSearchQuery) ||
(agent.description && fuzzyMatch(agentSearchQuery, agent.description)) (agent.description && fuzzyMatch(agent.description, agentSearchQuery))
); );
}, [selectableDesktopAgents, agentSearchQuery]); }, [selectableDesktopAgents, agentSearchQuery]);
@@ -518,13 +518,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const inputModalityIcons = getModalityIcons(currentMetadata, 'input'); const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output'); const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
// Providers/models can reload (directory switch/config sync) without changing
// currentProviderId/currentModelId; include providers to avoid stale variants.
const availableVariants = React.useMemo(() => { const availableVariants = React.useMemo(() => {
const variantKey = `${currentProviderId}/${currentModelId}`;
if (!variantKey) {
return [];
}
return getCurrentModelVariants(); return getCurrentModelVariants();
}, [getCurrentModelVariants, currentProviderId, currentModelId]); }, [getCurrentModelVariants, currentProviderId, currentModelId, providers]);
const hasVariants = availableVariants.length > 0; const hasVariants = availableVariants.length > 0;
const costRows = [ const costRows = [
@@ -1383,6 +1381,39 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
); );
}; };
const normalizeModelSearchValue = React.useCallback((value: string) => {
const lower = value.toLowerCase().trim();
const compact = lower.replace(/[^a-z0-9]/g, '');
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
return { lower, compact, tokens };
}, []);
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
const normalizedQuery = normalizeModelSearchValue(query);
if (!normalizedQuery.lower) {
return true;
}
const normalizedCandidate = normalizeModelSearchValue(candidate);
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
return true;
}
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
return true;
}
if (normalizedQuery.tokens.length === 0) {
return false;
}
return normalizedQuery.tokens.every((queryToken) =>
normalizedCandidate.tokens.some((candidateToken) =>
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
)
);
}, [normalizeModelSearchValue]);
const renderMobileModelPanel = () => { const renderMobileModelPanel = () => {
if (!isCompact) return null; if (!isCompact) return null;
@@ -1392,13 +1423,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const providerModels = Array.isArray(provider.models) ? provider.models : []; const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0 const matchesProvider = normalizedQuery.length === 0
? true ? true
: fuzzyMatch(provider.name, normalizedQuery) || fuzzyMatch(provider.id, normalizedQuery); : matchesModelSearch(provider.name, normalizedQuery) || matchesModelSearch(provider.id, normalizedQuery);
const matchingModels = normalizedQuery.length === 0 const matchingModels = normalizedQuery.length === 0
? providerModels ? providerModels
: providerModels.filter((model: ProviderModel) => { : providerModels.filter((model: ProviderModel) => {
const name = getModelDisplayName(model); const name = getModelDisplayName(model);
const id = typeof model.id === 'string' ? model.id : ''; const id = typeof model.id === 'string' ? model.id : '';
return fuzzyMatch(name, normalizedQuery) || fuzzyMatch(id, normalizedQuery); return matchesModelSearch(name, normalizedQuery) || matchesModelSearch(id, normalizedQuery);
}); });
return { provider, providerModels: matchingModels, matchesProvider }; return { provider, providerModels: matchingModels, matchesProvider };
}) })
@@ -1964,12 +1995,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
); );
}; };
// Filter models based on search query (fuzzy match) // Filter models based on search query
const filterByQuery = (modelName: string, providerName: string, query: string) => { const filterByQuery = (modelName: string, providerName: string, query: string) => {
if (!query.trim()) return true; if (!query.trim()) return true;
return ( return (
fuzzyMatch(modelName, query) || matchesModelSearch(modelName, query) ||
fuzzyMatch(providerName, query) matchesModelSearch(providerName, query)
); );
}; };
@@ -2024,9 +2055,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Handle keyboard navigation // Handle keyboard navigation
const handleModelKeyDown = (e: React.KeyboardEvent) => { const handleModelKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems)); setModelSelectedIndex((prev) => (prev + 1) % Math.max(1, totalItems));
// Scroll into view // Scroll into view
setTimeout(() => { setTimeout(() => {
@@ -2035,7 +2067,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, 0); }, 0);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setModelSelectedIndex((prev) => (prev - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems)); setModelSelectedIndex((prev) => (prev - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems));
// Scroll into view // Scroll into view
setTimeout(() => { setTimeout(() => {
@@ -2044,14 +2075,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, 0); }, 0);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const selectedItem = flatModelList[modelSelectedIndex]; const selectedItem = flatModelList[modelSelectedIndex];
if (selectedItem) { if (selectedItem) {
handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID); handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID);
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setAgentMenuOpen(false); setAgentMenuOpen(false);
} }
}; };
@@ -2492,6 +2521,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
placeholder="Search agents" placeholder="Search agents"
value={agentSearchQuery} value={agentSearchQuery}
onChange={(e) => setAgentSearchQuery(e.target.value)} onChange={(e) => setAgentSearchQuery(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
}}
className="pl-8 h-8 typography-meta" className="pl-8 h-8 typography-meta"
autoFocus autoFocus
/> />
@@ -541,9 +541,10 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
// Handle keyboard navigation // Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems);
setSelectedIndex(nextIndex); setSelectedIndex(nextIndex);
setTimeout(() => { setTimeout(() => {
@@ -551,7 +552,6 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}, 0); }, 0);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems);
setSelectedIndex(prevIndex); setSelectedIndex(prevIndex);
setTimeout(() => { setTimeout(() => {
@@ -559,14 +559,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}, 0); }, 0);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const selectedItem = flatModelList[selectedIndex]; const selectedItem = flatModelList[selectedIndex];
if (selectedItem) { if (selectedItem) {
handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID); handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID);
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
}; };