Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <AuthContext.Provider value={auth(username)}>{children}</AuthContext.Provider>;
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');
});
});
24 changes: 22 additions & 2 deletions frontend/src/v2/hooks/useV2ThreadMentions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<V2ComposerWarning[]>(() => {
if (!draft.includes('@')) return [];
Expand All @@ -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) => {
Expand Down
Loading