diff --git a/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx new file mode 100644 index 0000000000..f4f77aaa1e --- /dev/null +++ b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx @@ -0,0 +1,74 @@ +// @ts-nocheck +// The @ picker (C14 rehearsal, 2026-10-20): typing "@eng" listed the person's +// own account first and the agent second, so Enter mentioned themself and the +// agent never woke. The picker never offers your own handle, and among matches +// it puts prefix matches first and agents before people. +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { AuthContext } from '../../context/AuthContext'; +import { useV2ThreadMentions } from '../hooks/useV2ThreadMentions'; + +const auth = (username) => ({ + currentUser: { _id: 'u-self', username }, user: { _id: 'u-self', username }, token: 't', loading: false, + error: null, isAuthenticated: true, register: jest.fn(), login: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(), +}); + +const members = [ + { _id: 'u-self', username: 'laruiss' }, + { _id: 'u-other', username: 'larissa' }, + { _id: 'u-agnes', username: 'agnes' }, + { _id: 'b-1', username: 'laruiss-agent' }, +]; +const agents = [{ agentName: 'laruiss-agent', instanceId: 'default', displayName: 'laruiss-agent' }]; + +const open = (query, username = 'laruiss', podMembers = members) => { + const draft = `@${query}`; + const inputRef = { current: { selectionStart: draft.length, focus: jest.fn(), setSelectionRange: jest.fn() } }; + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useV2ThreadMentions({ + members: podMembers, agents, agentStateByHandle: new Map(), draft, setDraft: jest.fn(), inputRef, + }), { wrapper }); + act(() => { result.current.updateMentionState(draft, draft.length); }); + return result.current.filteredMentions.map((item) => item.value || item.label); +}; + +describe('@ picker', () => { + test('never offers your own handle', () => { + expect(open('lar')).not.toContain('laruiss'); + }); + + test('the agent comes first, so Enter picks it', () => { + expect(open('lar')[0]).toBe('laruiss-agent'); + }); + + test('other people are still offered, after the agent', () => { + const list = open('lar'); + expect(list).toContain('larissa'); + expect(list.indexOf('laruiss-agent')).toBeLessThan(list.indexOf('larissa')); + }); + + test('a prefix match outranks a match in the middle, even a person over an agent', () => { + // "ag" is a prefix of agnes and only inside laruiss-agent: the person + // typing "@ag" means agnes. + const list = open('ag'); + expect(list).toEqual(expect.arrayContaining(['agnes', 'laruiss-agent'])); + expect(list[0]).toBe('agnes'); + }); + + // sprint-review on #2366: with more matches than the picker shows, ranking + // must happen before the cap, or an agent late in pod order drops out. + test('ranking happens before the 8-item cap: the agent last in pod order still leads', () => { + const crowd = Array.from({ length: 10 }, (_, i) => ({ _id: `u-${i}`, username: `lar${i}` })); + const list = open('lar', 'laruiss', [...crowd, { _id: 'b-1', username: 'laruiss-agent' }]); + expect(list).toHaveLength(8); + expect(list[0]).toBe('laruiss-agent'); + }); + + test('the self-skip ignores case', () => { + expect(open('lar', 'LaRuiss')).not.toContain('laruiss'); + }); + + test('someone else signed in sees laruiss as a member', () => { + expect(open('lar', 'larissa')).toContain('laruiss'); + }); +}); diff --git a/frontend/src/v2/hooks/useV2ThreadMentions.ts b/frontend/src/v2/hooks/useV2ThreadMentions.ts index ea77845363..94e242e93e 100644 --- a/frontend/src/v2/hooks/useV2ThreadMentions.ts +++ b/frontend/src/v2/hooks/useV2ThreadMentions.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState, type RefObject } from 'react'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../../context/AuthContext'; import type { V2Agent } from './useV2PodDetail'; import type { V2PodMember } from './useV2Pods'; import type { V2ComposerMention, V2ComposerWarning } from '../components/V2Composer'; @@ -53,6 +54,8 @@ export const useV2ThreadMentions = ({ inputRef, }: UseV2ThreadMentionsArgs) => { const { t } = useTranslation(); + const { currentUser } = useAuth(); + const selfUsername = (currentUser?.username || '').toLowerCase(); const [mentionOpen, setMentionOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionStart, setMentionStart] = useState(-1); @@ -99,6 +102,10 @@ export const useV2ThreadMentions = ({ const username = member.username || ''; if (!username) return; const key = username.toLowerCase(); + // Never offer the person their own handle. It was the first suggestion + // for any prefix of their name, so Enter mentioned themself and the + // agent they meant never woke (C14 rehearsal, 2026-10-20). + if (selfUsername && key === selfUsername) return; if (seen.has(key)) return; seen.add(key); const agent = agentByUsername.get(key); @@ -128,7 +135,7 @@ export const useV2ThreadMentions = ({ if (item) items.push(item); }); return items; - }, [agentStateByHandle, agents, members, t]); + }, [agentStateByHandle, agents, members, selfUsername, t]); const warnings = useMemo(() => { if (!draft.includes('@')) return []; @@ -149,7 +156,20 @@ export const useV2ThreadMentions = ({ const filteredMentions = useMemo(() => { if (!mentionOpen) return []; const query = mentionQuery.trim().toLowerCase(); - return mentionableItems.filter((item) => item.labelLower.includes(query)).slice(0, 8); + // Prefix matches first, and agents before people among equals, so Enter + // lands on the agent the person is most likely addressing. Otherwise the + // pod's own order holds. + const prefix = (item: MentionItem) => ( + (item.value || '').toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query) + ); + return mentionableItems + .map((item, order) => ({ item, order })) + .filter(({ item }) => item.labelLower.includes(query)) + .sort((a, b) => (Number(prefix(b.item)) - Number(prefix(a.item))) + || (Number(b.item.isAgent) - Number(a.item.isAgent)) + || (a.order - b.order)) + .map(({ item }) => item) + .slice(0, 8); }, [mentionOpen, mentionQuery, mentionableItems]); const updateMentionState = useCallback((nextValue: string, cursorPosition: number | null) => {