From 78b2e3c8d6f1666240ca85eea52b9f220fb011f4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:27:24 -0700 Subject: [PATCH 1/2] fix(composer): the @ picker never offers yourself, and puts the agent first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C14 call rehearsal, 2026-10-20: typing "@eng" listed the person's own account first ("Member") and their agent second, so Enter mentioned themself and the agent never woke. On the call that is "@lar" + Enter → @laruiss. - The current user is never a candidate (read from AuthContext inside the hook; V2Thread, its only caller, already sits under it). - Among matches, prefix matches come first, then agents before people, then the pod's own order. "@lar" picks laruiss-agent; "@ag" still picks agnes over a mid-word agent match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../v2/__tests__/useV2ThreadMentions.test.tsx | 61 +++++++++++++++++++ frontend/src/v2/hooks/useV2ThreadMentions.ts | 24 +++++++- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx diff --git a/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx new file mode 100644 index 0000000000..ea1a086a84 --- /dev/null +++ b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx @@ -0,0 +1,61 @@ +// @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') => { + const draft = `@${query}`; + const inputRef = { current: { selectionStart: draft.length, focus: jest.fn(), setSelectionRange: jest.fn() } }; + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useV2ThreadMentions({ + members, 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'); + }); + + 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) => { From d2c558f7c3ea16aa5d7de128c9eb02f3e05a2fc7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:11:49 -0700 Subject: [PATCH 2/2] test(composer): pin rank-before-cap and a case-blind self-skip in the @ picker sprint-review on #2366 (PASS at 78b2e3c8) found two properties the shipped code holds and no arm pinned: - Ranking before the 8-item cap. With ten people matching and the agent last in pod order, capping first drops the agent from the picker. New arm with 11 candidates; moving .slice(0, 8) above the sort now fails it. - The self-skip ignores case. A signed-in "LaRuiss" against the member row "laruiss" must still be skipped; dropping .toLowerCase() now fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../v2/__tests__/useV2ThreadMentions.test.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx index ea1a086a84..f4f77aaa1e 100644 --- a/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx +++ b/frontend/src/v2/__tests__/useV2ThreadMentions.test.tsx @@ -21,12 +21,12 @@ const members = [ ]; const agents = [{ agentName: 'laruiss-agent', instanceId: 'default', displayName: 'laruiss-agent' }]; -const open = (query, username = 'laruiss') => { +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, agents, agentStateByHandle: new Map(), draft, setDraft: jest.fn(), inputRef, + 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); @@ -55,6 +55,19 @@ describe('@ picker', () => { 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'); });