feat(FN-3809): normalize mention filtering for underscore handles

Added a `mentionMatching.ts` utility to normalize agent mention filtering across the codebase, fixing the handling of handles containing underscores. Updated `AgentMentionPopup`, `ChatView`, and `QuickChatFAB` to use the new utility, with corresponding tests added including a new `mentionMatching.te

Fusion-Task-Id: FN-3809
This commit is contained in:
Fusion
2026-05-09 07:13:28 -07:00
committed by gsxdsm
parent 8051bea95a
commit b33b640d02
6 changed files with 55 additions and 25 deletions

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { AgentAvatar } from "./AgentAvatar";
import "./AgentMentionPopup.css";
import type { Agent } from "@fusion/core";
import { matchesAgentMentionFilter } from "./mentionMatching";
interface AgentMentionPopupProps {
/** List of agents to show */
@@ -26,14 +27,7 @@ export function AgentMentionPopup({
onSelect,
position = "below",
}: AgentMentionPopupProps) {
const filteredAgents = useMemo(() => {
const normalizedFilter = filter.trim().toLowerCase();
if (!normalizedFilter) {
return agents;
}
return agents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter));
}, [agents, filter]);
const filteredAgents = useMemo(() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, filter)), [agents, filter]);
if (!visible) {
return null;

View File

@@ -37,6 +37,7 @@ import { CreateRoomModal, type RoomDraft } from "./CreateRoomModal";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { matchesAgentMentionFilter } from "./mentionMatching";
export interface ChatViewProps {
projectId?: string;
@@ -860,13 +861,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const mentionAgents = useMemo(() => Array.from(agentsMap.values()), [agentsMap]);
const filteredMentionAgents = useMemo(() => {
const normalizedFilter = mentionFilter.trim().toLowerCase();
if (!normalizedFilter) {
return mentionAgents;
}
return mentionAgents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter));
}, [mentionAgents, mentionFilter]);
const filteredMentionAgents = useMemo(
() => mentionAgents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
[mentionAgents, mentionFilter],
);
const mentionAgentsByName = useMemo(() => {
const byName = new Map<string, Agent>();

View File

@@ -19,6 +19,7 @@ import type { DiscoveredSkill } from "@fusion/dashboard";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { matchesAgentMentionFilter } from "./mentionMatching";
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
import { useAgents } from "../hooks/useAgents";
import { FileMentionPopup } from "./FileMentionPopup";
@@ -1344,14 +1345,10 @@ export function QuickChatFAB({
return matchingSkills.slice(0, 10);
}, [discoveredSkills, skillFilter]);
const filteredMentionAgents = useMemo(() => {
const normalizedFilter = mentionFilter.trim().toLowerCase();
if (!normalizedFilter) {
return agents;
}
return agents.filter((agent) => agent.name.toLowerCase().includes(normalizedFilter));
}, [agents, mentionFilter]);
const filteredMentionAgents = useMemo(
() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, mentionFilter)),
[agents, mentionFilter],
);
const mentionAgentsByName = useMemo(() => {
const byName = new Map<string, Agent>();

View File

@@ -51,11 +51,14 @@ describe("AgentMentionPopup", () => {
expect(screen.getByTestId("agent-mention-item-agent-002")).toBeInTheDocument();
});
it("filters agents by name case-insensitively", () => {
it.each([
["review", "filters agents by name case-insensitively"],
["beta_re", "matches underscore handles for agents with spaces"],
])("%s %s", (filter) => {
render(
<AgentMentionPopup
agents={agents}
filter="review"
filter={filter}
highlightedIndex={0}
visible={true}
onSelect={vi.fn()}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { matchesAgentMentionFilter, normalizeMentionToken } from "../mentionMatching";
describe("mentionMatching", () => {
it("normalizes spaces and hyphens into underscores", () => {
expect(normalizeMentionToken(" John Doe-Agent ")).toBe("john_doe_agent");
});
it.each([
["John Doe", "john_d"],
["John-Doe", "john_d"],
["John Doe", "john doe"],
])("matches %s with filter %s", (agentName, filter) => {
expect(matchesAgentMentionFilter(agentName, filter)).toBe(true);
});
it("returns false when filter does not match", () => {
expect(matchesAgentMentionFilter("John Doe", "alpha")).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
export function normalizeMentionToken(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_")
.replace(/_+/g, "_");
}
export function matchesAgentMentionFilter(agentName: string, filter: string): boolean {
const trimmedFilter = filter.trim();
if (!trimmedFilter) {
return true;
}
const normalizedFilter = normalizeMentionToken(trimmedFilter);
const normalizedAgentName = normalizeMentionToken(agentName);
return normalizedAgentName.includes(normalizedFilter) || agentName.toLowerCase().includes(trimmedFilter.toLowerCase());
}