diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2ff..f70a8a9d3ad 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -538,3 +538,20 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +test("coalesceAgentAutocompleteCandidates: keeps same-name global people distinct", () => { + const first = makeAgent({ + pubkey: PUB_A, + displayName: "Will", + isAgent: false, + isGlobalSearchResult: true, + }); + const second = makeAgent({ + pubkey: PUB_B, + displayName: "Will", + isAgent: false, + isGlobalSearchResult: true, + }); + + assert.deepEqual(coalesce([first, second]), [first, second]); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92e..7d028adbd6d 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -317,29 +317,6 @@ function isPreferredAgentCandidate( return false; } -export function coalesceAutocompleteCandidatesByKey( - candidates: readonly T[], - getKey: (candidate: T) => string | null, -) { - const output: T[] = []; - const indexesByKey = new Map(); - - for (const candidate of candidates) { - const key = getKey(candidate); - if (!key) { - output.push(candidate); - continue; - } - - if (!indexesByKey.has(key)) { - indexesByKey.set(key, output.length); - output.push(candidate); - } - } - - return output; -} - export function coalesceAgentAutocompleteCandidates< T extends AgentAutocompleteCandidate, >( diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index 589d4c8c7ad..715f082d225 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -7,7 +7,11 @@ import { import { truncatePubkey } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { useUserSearchQuery } from "@/features/profile/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUserSearchFetchMoreOnScroll, +} from "@/features/profile/hooks"; import type { RespondToMode, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; @@ -66,10 +70,13 @@ function formatSearchUserName(user: UserSearchResult) { function formatSearchUserSecondary(user: UserSearchResult) { const displayName = user.displayName?.trim(); const nip05Handle = user.nip05Handle?.trim(); - if (displayName && nip05Handle) { - return nip05Handle; - } - return truncatePubkey(user.pubkey); + return displayName && nip05Handle ? nip05Handle : null; +} + +function formatSearchUserAccessibleName(user: UserSearchResult) { + const name = formatSearchUserName(user); + const secondary = formatSearchUserSecondary(user); + return `Add ${name}${secondary ? `, ${secondary}` : ""}, public key ${user.pubkey}`; } const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ @@ -123,20 +130,40 @@ export function CreateAgentRespondToField({ () => new Set(allowlist.map((p) => p.toLowerCase())), [allowlist], ); - const userSearchQuery = useUserSearchQuery(deferredQuery, { + const userSearchQuery = useInfiniteUserSearchQuery(deferredQuery, { enabled: mode === "allowlist" && deferredQuery.length > 0, - limit: 8, + limit: 50, }); const isArchivedDiscovery = useIsArchivedPredicate(); + const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); const searchResults = React.useMemo( () => - (userSearchQuery.data ?? []).filter( + userSearchResults.filter( (user) => !allowlistSet.has(user.pubkey.toLowerCase()) && !isArchivedDiscovery(user.pubkey), ), - [allowlistSet, isArchivedDiscovery, userSearchQuery.data], + [allowlistSet, isArchivedDiscovery, userSearchResults], ); + const searchViewportRef = React.useRef(null); + React.useEffect(() => { + const viewport = searchViewportRef.current; + if ( + !viewport || + !userSearchQuery.hasNextPage || + userSearchQuery.isFetchingNextPage || + viewport.scrollHeight > viewport.clientHeight + ) { + return; + } + + void userSearchQuery.fetchNextPage(); + }, [ + userSearchQuery.fetchNextPage, + userSearchQuery.hasNextPage, + userSearchQuery.isFetchingNextPage, + ]); + const handleSearchScroll = useUserSearchFetchMoreOnScroll(userSearchQuery); const pasteParsed = React.useMemo( () => parsePubkeyInput(pasteText), @@ -254,6 +281,8 @@ export function CreateAgentRespondToField({ onAddFromPaste={handleAddFromPaste} onAddRawPubkey={handleAddRawPubkey} onAddSearchResult={handleAddSearchResult} + onSearchScroll={handleSearchScroll} + searchViewportRef={searchViewportRef} onPasteTextChange={setPasteText} onQueryChange={setQuery} onRemove={handleRemove} @@ -288,6 +317,8 @@ function AllowlistPicker({ onAddFromPaste, onAddRawPubkey, onAddSearchResult, + onSearchScroll, + searchViewportRef, onPasteTextChange, onQueryChange, onRemove, @@ -309,6 +340,8 @@ function AllowlistPicker({ onAddFromPaste: () => void; onAddRawPubkey: (pubkey: string) => void; onAddSearchResult: (user: UserSearchResult) => void; + onSearchScroll: (event: React.UIEvent) => void; + searchViewportRef: React.RefObject; onPasteTextChange: (value: string) => void; onQueryChange: (value: string) => void; onRemove: (pubkey: string) => void; @@ -405,63 +438,81 @@ function AllowlistPicker({

Searching…

- ) : searchResults.length > 0 ? ( -
- {searchResults.map((result) => ( + ) : ( +
+ {searchResults.length > 0 ? ( + searchResults.map((result) => ( +
+
+ +
+

+ {formatSearchUserName(result)} +

+ {formatSearchUserSecondary(result) ? ( +

+ {formatSearchUserSecondary(result)} +

+ ) : null} + +
+
+ +
+ )) + ) : queryIsHexPubkey ? ( - ))} + ) : ( +

+ No matching users. +

+ )}
- ) : queryIsHexPubkey ? ( - - ) : ( -

- No matching users. -

)}
) : null} diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 3ad358a0d66..2f5731557fa 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -57,22 +57,6 @@ export function mentionCandidateLabel(candidate: MentionCandidate) { ); } -export function globalSearchIdentityKey(candidate: MentionCandidate) { - if ( - !candidate.isGlobalSearchResult || - candidate.isMember || - candidate.isAgent - ) { - return null; - } - - const label = candidate.displayName?.trim().toLowerCase(); - if (!label) return null; - - const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? ""; - return `global-person:${label}:${secondaryLabel}`; -} - function findTeamMemberTarget( persona: AgentPersona, candidates: readonly MentionCandidate[], diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index bf145713d50..557388109f3 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -13,7 +13,6 @@ import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { coalesceAgentAutocompleteCandidates, - coalesceAutocompleteCandidatesByKey, filterAdmittedMentionPubkeys, filterCachedAgentSuggestions, getAdmittedAgentPubkeys, @@ -53,7 +52,6 @@ import { formatSearchUserDisplayName, formatSearchUserSecondaryLabel, formatTeamMention, - globalSearchIdentityKey, type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; @@ -393,10 +391,7 @@ export function useMentions( })) .filter((candidate) => candidate.displayName.trim().length > 0); return coalesceAgentAutocompleteCandidates( - coalesceAutocompleteCandidatesByKey( - [...candidatesByPubkey.values(), ...personaCandidates], - globalSearchIdentityKey, - ), + [...candidatesByPubkey.values(), ...personaCandidates], { currentPubkey, getLabel: mentionCandidateLabel, diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index adb9c6d58ab..99e73e9b5a2 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -128,6 +128,146 @@ test("open agent access explains the available access before save", async ({ await expect(warning).toHaveCount(0); }); +test("selected-people search keeps same-name people independently selectable", async ({ + page, +}) => { + const firstWillPubkey = "1".repeat(64); + const secondWillPubkey = "2".repeat(64); + const agent = TEST_IDENTITIES.charlie; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Hack Day Helper", + status: "running", + channelNames: ["general"], + respondTo: "owner-only", + }, + ], + searchProfiles: [ + { + pubkey: firstWillPubkey, + displayName: "Will", + nip05Handle: "will@example.com", + }, + { + pubkey: secondWillPubkey, + displayName: "Will", + nip05Handle: "will@example.com", + }, + ], + }); + await page.goto("/"); + await openAgentAccessDialog(page, agent.pubkey); + + await page.getByTestId("agent-respond-to-select").selectOption("allowlist"); + await page.getByTestId("agent-respond-to-search").fill("Will"); + + const firstWill = page.getByTestId( + `agent-respond-to-result-${firstWillPubkey}`, + ); + const secondWill = page.getByTestId( + `agent-respond-to-result-${secondWillPubkey}`, + ); + await expect(firstWill).toContainText("Will"); + await expect(firstWill).toContainText("will@example.com"); + await expect(firstWill).toContainText("11111111…1111"); + await expect(secondWill).toContainText("Will"); + await expect(secondWill).toContainText("will@example.com"); + await expect(secondWill).toContainText("22222222…2222"); + const firstAdd = firstWill.getByRole("button", { + name: `Add Will, will@example.com, public key ${firstWillPubkey}`, + }); + const secondAdd = secondWill.getByRole("button", { + name: `Add Will, will@example.com, public key ${secondWillPubkey}`, + }); + await expect(firstAdd).toBeVisible(); + await expect(secondAdd).toBeVisible(); + await page + .getByTestId("agent-respond-to-allowlist") + .screenshot({ path: `${SHOTS}/duplicate-will-selected-people.png` }); + + await secondAdd.focus(); + await secondAdd.press("Enter"); + await expect( + page.getByTestId(`agent-respond-to-chip-${secondWillPubkey}`), + ).toBeVisible(); + await expect( + page.getByTestId(`agent-respond-to-chip-${firstWillPubkey}`), + ).toHaveCount(0); + await page.getByRole("button", { name: "Save access" }).click(); + const updateCommand = await page.evaluate( + (agentPubkey) => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { pubkey?: string } })?.input?.pubkey === + agentPubkey, + ), + agent.pubkey, + ); + expect(updateCommand?.payload).toMatchObject({ + input: { + pubkey: agent.pubkey, + respondTo: "allowlist", + respondToAllowlist: [secondWillPubkey], + }, + }); + expect(updateCommand?.payload).not.toMatchObject({ + input: { respondToAllowlist: [firstWillPubkey] }, + }); +}); + +for (const selectedFirstPageCount of [50, 49]) { + test(`selected-people search fetches past a ${ + selectedFirstPageCount === 50 ? "fully filtered" : "non-scrollable" + } first page`, async ({ page }) => { + const firstPageProfiles = Array.from({ length: 50 }, (_, index) => ({ + pubkey: (index + 1).toString(16).padStart(64, "0"), + displayName: `Will ${String(index).padStart(2, "0")}`, + })); + const targetPubkey = "f".repeat(64); + const agent = TEST_IDENTITIES.charlie; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Hack Day Helper", + status: "running", + channelNames: ["general"], + respondTo: "allowlist", + respondToAllowlist: firstPageProfiles + .slice(0, selectedFirstPageCount) + .map((profile) => profile.pubkey), + }, + ], + searchProfiles: [ + ...firstPageProfiles, + { pubkey: targetPubkey, displayName: "Will Target" }, + ], + }); + await page.goto("/"); + await openAgentAccessDialog(page, agent.pubkey); + + await page.getByTestId("agent-respond-to-search").fill("Will"); + + await expect + .poll( + () => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => entry.command === "search_users") + .map((entry) => entry.payload), + ), + { message: "search should request the second result page" }, + ) + .toContainEqual(expect.objectContaining({ cursor: "2" })); + await expect( + page.getByTestId(`agent-respond-to-result-${targetPubkey}`), + ).toContainText("Will Target"); + }); +} + test("full agent editor tightens the exact sidebar agent instance", async ({ page, }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e2b6db39300..b7cece6ac8d 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -8,6 +8,7 @@ import { const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const SHOTS = "test-results/mentions"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -686,6 +687,53 @@ test("autocomplete searches global non-member people from the first typed charac await expect(tessaRow.getByText("not in channel")).toBeVisible(); }); +for (const selectedWill of ["first", "second"] as const) { + test(`autocomplete selects the exact ${selectedWill} same-name global person`, async ({ + page, + }) => { + const firstWillPubkey = "1".repeat(64); + const secondWillPubkey = "2".repeat(64); + const selectedPubkey = + selectedWill === "first" ? firstWillPubkey : secondWillPubkey; + await installMockBridge(page, { + searchProfiles: [ + { pubkey: firstWillPubkey, displayName: "Will" }, + { pubkey: secondWillPubkey, displayName: "Will" }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + await page.getByTestId("message-input").fill("@Will"); + + const dropdown = autocomplete(page); + const firstWill = dropdown.getByTestId( + `mention-suggestion-${firstWillPubkey}`, + ); + const secondWill = dropdown.getByTestId( + `mention-suggestion-${secondWillPubkey}`, + ); + await expect(firstWill).toBeVisible(); + await expect(secondWill).toBeVisible(); + await expect(dropdown.getByTestId("mention-collision-npub")).toHaveCount(2); + if (selectedWill === "first") { + await dropdown.screenshot({ + path: `${SHOTS}/duplicate-will-autocomplete.png`, + }); + } + + await dropdown.getByTestId(`mention-suggestion-${selectedPubkey}`).click(); + await page.keyboard.type(`hello ${selectedWill}`); + const message = `@Will hello ${selectedWill}`; + await expect(page.getByTestId("message-input")).toHaveText(message); + await page.getByTestId("send-message").click(); + await page.getByRole("button", { name: "Invite" }).click(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, message)) + .toEqual([selectedPubkey]); + }); +} + test("mention autocomplete caps global people search at 50 results", async ({ page, }) => {