feat(desktop): disambiguate agent name collisions

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-16 20:45:26 -07:00
parent 43bd104de5
commit df3d388d22
8 changed files with 358 additions and 35 deletions

View File

@@ -47,6 +47,7 @@ export default defineConfig({
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/ambiguous-agent-owner-suffix.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",

View File

@@ -20,7 +20,12 @@ import {
useUserSearchFetchMoreOnScroll,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
formatOwnerLabel,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -374,6 +379,13 @@ export function MembersSidebar({
userSearchResults,
rawMembers,
]);
const addSearchNameCounts = React.useMemo(
() =>
countVisibleDisplayNames(
addSearchResults.map((user) => formatAddCandidateName(user)),
),
[addSearchResults],
);
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
@@ -708,23 +720,39 @@ export function MembersSidebar({
Not in this channel
</SearchResultSectionTitle>
) : null}
{addSearchResults.map((user) => (
<AddMemberSearchResultRow
disabled={
addingMemberPubkeys.has(user.pubkey) || isArchived
}
key={user.pubkey}
onSelect={(selectedUser) => {
void handleAddSearchResult(selectedUser);
}}
ownerLabel={formatOwnerLabel(
user.ownerPubkey,
identityQuery.data?.pubkey,
addSearchOwnerProfilesQuery.data?.profiles,
)}
user={user}
/>
))}
{addSearchResults.map((user) => {
const ownerLabel = formatOwnerLabel(
user.ownerPubkey,
identityQuery.data?.pubkey,
addSearchOwnerProfilesQuery.data?.profiles,
);
const displayName = formatAddCandidateName(user);
return (
<AddMemberSearchResultRow
disabled={
addingMemberPubkeys.has(user.pubkey) ||
isArchived
}
displayName={formatDisambiguatedAgentName({
displayName,
hasNameCollision:
hasVisibleDisplayNameCollision(
displayName,
addSearchNameCounts,
),
isAgent: user.isAgent,
ownerLabel,
})}
key={user.pubkey}
onSelect={(selectedUser) => {
void handleAddSearchResult(selectedUser);
}}
ownerLabel={ownerLabel}
user={user}
/>
);
})}
{isAddSearchLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
Searching...
@@ -851,11 +879,13 @@ function SearchResultSectionTitle({
function AddMemberSearchResultRow({
disabled,
displayName,
onSelect,
ownerLabel,
user,
}: {
disabled: boolean;
displayName: string;
onSelect: (user: UserSearchResult) => void;
ownerLabel?: string | null;
user: UserSearchResult;
@@ -869,7 +899,7 @@ function AddMemberSearchResultRow({
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
aria-label={`Select ${displayName}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
disabled={disabled}
onClick={() => onSelect(user)}
@@ -885,8 +915,11 @@ function AddMemberSearchResultRow({
{user.isAgent ? (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
<span
className="truncate text-sm font-medium tracking-tight"
data-testid="channel-user-search-result-name"
>
{displayName}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-4 w-4" />

View File

@@ -12,6 +12,11 @@ import {
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";
export type MentionSuggestion = {
pubkey?: string;
@@ -66,12 +71,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
// Name collisions are the impersonation vector: a vanity-ground key can
// wear any display name. When two suggestions share a name, surface each
// one's npub (truncated; full key in the hover tooltip) to tell them apart.
const nameCounts = new Map<string, number>();
for (const suggestion of suggestions) {
const name = suggestion.displayName.toLowerCase();
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
// one's owner in the title (when known) and npub (truncated; full key in the
// hover tooltip) to tell them apart.
const nameCounts = countVisibleDisplayNames(
suggestions.map((suggestion) => suggestion.displayName),
);
return (
<div
@@ -101,8 +105,16 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
suggestion.displayName;
const agentLabel = "agent";
const hasNameCollision =
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
const hasNameCollision = hasVisibleDisplayNameCollision(
suggestion.displayName,
nameCounts,
);
const displayName = formatDisambiguatedAgentName({
displayName: suggestion.displayName,
hasNameCollision,
isAgent: suggestion.isAgent,
ownerLabel: suggestion.ownerLabel,
});
const collisionNpub =
hasNameCollision && suggestion.pubkey
? safeNpub(suggestion.pubkey)
@@ -140,9 +152,10 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className="min-w-0 break-words font-medium leading-snug"
title={suggestion.displayName}
data-testid="mention-suggestion-name"
title={displayName}
>
{suggestion.displayName}
{displayName}
</span>
{suggestion.kind === "team" ||
suggestion.isAgent ||

View File

@@ -1,6 +1,9 @@
import { Bot } from "lucide-react";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import {
formatDisambiguatedAgentName,
formatOwnerLabel,
} from "@/features/profile/lib/identity";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { UserSearchResult } from "@/shared/api/types";
@@ -67,6 +70,7 @@ function HoverRecipientIdentity({
export function NewMessageResultRow({
currentPubkey,
disabled,
hasNameCollision = false,
isAlreadySelected = false,
isKeyboardHighlighted = false,
onSelect,
@@ -75,6 +79,7 @@ export function NewMessageResultRow({
}: {
currentPubkey?: string;
disabled: boolean;
hasNameCollision?: boolean;
isAlreadySelected?: boolean;
isKeyboardHighlighted?: boolean;
onSelect: (user: UserSearchResult) => void;
@@ -87,6 +92,12 @@ export function NewMessageResultRow({
currentPubkey,
ownerProfiles,
);
const displayName = formatDisambiguatedAgentName({
displayName: name,
hasNameCollision,
isAgent: user.isAgent,
ownerLabel,
});
return (
<div
@@ -94,7 +105,7 @@ export function NewMessageResultRow({
data-keyboard-highlighted={isKeyboardHighlighted ? "true" : undefined}
>
<button
aria-label={`${isAlreadySelected ? "Already added" : "Add"} ${name}`}
aria-label={`${isAlreadySelected ? "Already added" : "Add"} ${displayName}`}
aria-selected={isAlreadySelected || isKeyboardHighlighted}
className={cn(
"group/dm-result flex min-h-14 w-full cursor-pointer items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
@@ -120,7 +131,7 @@ export function NewMessageResultRow({
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1">
<HoverRecipientIdentity
displayName={name}
displayName={displayName}
pubkey={user.pubkey}
/>
</div>

View File

@@ -7,6 +7,10 @@ import {
} from "@/features/channels/hooks";
import type { Channel } from "@/shared/api/types";
import { useSendMessageMutation } from "@/features/messages/hooks";
import {
countVisibleDisplayNames,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";
import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -72,6 +76,13 @@ export function NewMessageScreen() {
const isSearchTransitionPending = searchQuery.trim() !== deferredSearchQuery;
const visibleSearchResults =
isSearchTransitionPending || isDirectoryLoading ? [] : searchResults;
const visibleSearchNameCounts = React.useMemo(
() =>
countVisibleDisplayNames(
visibleSearchResults.map((user) => formatRecipientName(user)),
),
[visibleSearchResults],
);
const showRecipientPicker = isRecipientPickerOpen && !isPending;
const highlightedRecipientIndex = React.useMemo(() => {
if (!showRecipientPicker || visibleSearchResults.length === 0) {
@@ -518,6 +529,10 @@ export function NewMessageScreen() {
isKeyboardHighlighted={
highlightedRecipient?.pubkey === user.pubkey
}
hasNameCollision={hasVisibleDisplayNameCollision(
formatRecipientName(user),
visibleSearchNameCounts,
)}
key={user.pubkey}
onSelect={handleResultSelect}
ownerProfiles={ownerProfiles}

View File

@@ -1,7 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { profileLookupsEqual } from "./identity.ts";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
hasVisibleDisplayNameCollision,
profileLookupsEqual,
} from "./identity.ts";
test("formatDisambiguatedAgentName appends an owner for colliding agents", () => {
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: true,
ownerLabel: "sergior",
}),
"Bumble (sergior)",
);
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: true,
ownerLabel: "you",
}),
"Bumble (you)",
);
});
test("formatDisambiguatedAgentName leaves humans and unique agents unchanged", () => {
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: false,
ownerLabel: null,
}),
"Bumble",
);
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: false,
isAgent: true,
ownerLabel: "sergior",
}),
"Bumble",
);
});
test("visible display-name collisions are case-insensitive", () => {
const counts = countVisibleDisplayNames(["Bumble", " bUmBlE ", "Fizz"]);
assert.equal(hasVisibleDisplayNameCollision("bumble", counts), true);
assert.equal(hasVisibleDisplayNameCollision("Fizz", counts), false);
});
const summary = (over = {}) => ({
displayName: "Ada",

View File

@@ -191,3 +191,48 @@ export function formatOwnerLabel(
truncatePubkey(ownerPubkey)
);
}
/**
* Appends an agent's owner to a display name only when that name collides with
* another visible result. The returned label is presentation-only; callers
* keep the original display name for selection and mention insertion.
*/
export function formatDisambiguatedAgentName(input: {
displayName: string;
hasNameCollision: boolean;
isAgent?: boolean;
ownerLabel?: string | null;
}) {
const { displayName, hasNameCollision, isAgent, ownerLabel } = input;
const normalizedOwnerLabel = ownerLabel?.trim();
if (!hasNameCollision || !isAgent || !normalizedOwnerLabel) {
return displayName;
}
return `${displayName} (${normalizedOwnerLabel})`;
}
/** Case-insensitive display-name counts for a currently visible result set. */
export function countVisibleDisplayNames(
displayNames: readonly (string | null | undefined)[],
) {
const counts = new Map<string, number>();
for (const displayName of displayNames) {
const normalizedName = displayName?.trim().toLowerCase();
if (!normalizedName) {
continue;
}
counts.set(normalizedName, (counts.get(normalizedName) ?? 0) + 1);
}
return counts;
}
export function hasVisibleDisplayNameCollision(
displayName: string,
counts: ReadonlyMap<string, number>,
) {
return (counts.get(displayName.trim().toLowerCase()) ?? 0) > 1;
}

View File

@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import {
installMockBridge,
openNewMessagePage,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SHOTS = "test-results/ambiguous-agent-owner-suffix";
const CURRENT_OWNER_PUBKEY = "deadbeef".repeat(8);
const FIRST_AGENT_PUBKEY = "1010".repeat(16);
const SECOND_AGENT_PUBKEY = "2020".repeat(16);
const duplicateAgentOptions = {
relayAgents: [
{
pubkey: FIRST_AGENT_PUBKEY,
name: "Echo",
respondTo: "allowlist" as const,
respondToAllowlist: [CURRENT_OWNER_PUBKEY],
},
{
pubkey: SECOND_AGENT_PUBKEY,
name: "eChO",
respondTo: "allowlist" as const,
respondToAllowlist: [CURRENT_OWNER_PUBKEY],
},
],
searchProfiles: [
{
pubkey: FIRST_AGENT_PUBKEY,
displayName: "Echo",
ownerPubkey: CURRENT_OWNER_PUBKEY,
isAgent: true,
},
{
pubkey: SECOND_AGENT_PUBKEY,
displayName: "eChO",
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
isAgent: true,
},
],
};
test("captures duplicate agent names in mention autocomplete", async ({
page,
}) => {
await installMockBridge(page, duplicateAgentOptions);
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill("@echo");
const autocomplete = page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete");
const firstRow = autocomplete.getByTestId(
`mention-suggestion-${FIRST_AGENT_PUBKEY}`,
);
const secondRow = autocomplete.getByTestId(
`mention-suggestion-${SECOND_AGENT_PUBKEY}`,
);
await expect(firstRow.getByTestId("mention-suggestion-name")).toHaveText(
"Echo (you)",
);
await expect(secondRow.getByTestId("mention-suggestion-name")).toHaveText(
"eChO (outsider)",
);
await expect(firstRow).toContainText("owned by you");
await expect(secondRow).toContainText("owned by outsider");
await waitForAnimations(page);
await autocomplete.screenshot({ path: `${SHOTS}/after-mention.png` });
});
test("captures duplicate agent names in add-member results", async ({
page,
}) => {
await installMockBridge(page, duplicateAgentOptions);
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-members-trigger").click();
await page.getByTestId("channel-management-search-users").fill("echo");
const firstRow = page.getByTestId(
`channel-user-search-result-${FIRST_AGENT_PUBKEY}`,
);
const secondRow = page.getByTestId(
`channel-user-search-result-${SECOND_AGENT_PUBKEY}`,
);
await expect(
firstRow.getByTestId("channel-user-search-result-name"),
).toHaveText("Echo (you)");
await expect(
secondRow.getByTestId("channel-user-search-result-name"),
).toHaveText("eChO (outsider)");
await expect(
firstRow.getByRole("button", { name: "Select Echo (you)", exact: true }),
).toBeVisible();
await expect(
secondRow.getByRole("button", {
name: "Select eChO (outsider)",
exact: true,
}),
).toBeVisible();
await expect(firstRow).toContainText("owned by you");
await expect(secondRow).toContainText("owned by outsider");
await waitForAnimations(page);
await page
.getByTestId("members-sidebar-people")
.screenshot({ path: `${SHOTS}/after-add-member.png` });
});
test("captures duplicate agent names in new-message results", async ({
page,
}) => {
await installMockBridge(page, duplicateAgentOptions);
await page.goto("/");
await openNewMessagePage(page);
await page.getByTestId("new-dm-search").fill("echo");
const firstRow = page.getByTestId(`new-dm-result-${FIRST_AGENT_PUBKEY}`);
const secondRow = page.getByTestId(`new-dm-result-${SECOND_AGENT_PUBKEY}`);
await expect(
firstRow
.getByTestId(`new-dm-name-${FIRST_AGENT_PUBKEY}`)
.getByText("Echo (you)", { exact: true }),
).toBeVisible();
await expect(
secondRow
.getByTestId(`new-dm-name-${SECOND_AGENT_PUBKEY}`)
.getByText("eChO (outsider)", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("option", { name: "Add Echo (you)", exact: true }),
).toBeVisible();
await expect(
page.getByRole("option", {
name: "Add eChO (outsider)",
exact: true,
}),
).toBeVisible();
await expect(firstRow).toContainText("owned by you");
await expect(secondRow).toContainText("owned by outsider");
await waitForAnimations(page);
await page
.getByTestId("new-message-recipient-popover")
.screenshot({ path: `${SHOTS}/after-new-message.png` });
});