From 348871376335dba16c857bfde8eb0432ab84eefb Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Thu, 2 Jul 2026 01:13:24 -0700 Subject: [PATCH] feat(messages): show owner avatar inline for duplicate-named agents When a room has more than one agent with the same displayed name, render the agent owner's 16px avatar between the username and the timestamp (zero row-height change), and label the profile balloon "Username's {agentname}." so humans can tell which clone is which. - formatTimelineMessages: duplicate-name detection from channel members (agents only, case-insensitive); attaches agentOwner only when disambiguation is needed - profile/hooks: useAgentOwnerProfilesQuery batch-fetches NIP-OA owner pubkeys; ChannelScreen merges them into the profile lookup - MessageRow: inline owner avatar with tooltip; popover gets displayNameOverride; memo comparator extended for agentOwner - UserAvatar: new 2xs (16px) size - tests: 6 formatter unit tests + e2e spec seeding two same-named agents (asserts avatar presence/absence, balloon text, row height) Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 18 +- .../lib/formatTimelineMessages.test.mjs | 152 +++++++++++++++++ .../messages/lib/formatTimelineMessages.ts | 45 +++++ desktop/src/features/messages/types.ts | 9 + .../src/features/messages/ui/MessageRow.tsx | 46 ++++- desktop/src/features/profile/hooks.ts | 24 +++ .../profile/ui/UserProfilePopover.tsx | 9 +- desktop/src/shared/ui/UserAvatar.tsx | 3 +- .../tests/e2e/duplicate-agent-avatars.spec.ts | 161 ++++++++++++++++++ 10 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 desktop/tests/e2e/duplicate-agent-avatars.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 46c8bd9d80..15769813b8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -64,6 +64,7 @@ export default defineConfig({ "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", + "**/duplicate-agent-avatars.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 659f8c44d9..d3afaecd91 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -50,7 +50,10 @@ import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAnces import { useThreadReplies } from "@/features/messages/useThreadReplies"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useAgentOwnerProfilesQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types"; import { useChannelFind } from "@/features/search/useChannelFind"; @@ -347,6 +350,11 @@ export function ChannelScreen({ (messageProfilePubkeys.length > 0 && (messageProfilesQuery.isPending || messageProfilesQuery.isPlaceholderData))); + // Owners referenced by agent profiles (NIP-OA ownerPubkey) are a + // second-level fetch; see useAgentOwnerProfilesQuery. + const agentOwnerProfilesQuery = useAgentOwnerProfilesQuery( + messageProfilesQuery.data?.profiles, + ); const { agentSessionCandidates, botTypingEntries, @@ -387,7 +395,12 @@ export function ChannelScreen({ const messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( - messageProfilesQuery.data?.profiles, + { + // Owner profiles first: an owner who also authored messages keeps + // their richer first-batch entry. + ...agentOwnerProfilesQuery.data?.profiles, + ...messageProfilesQuery.data?.profiles, + }, currentProfile, ) ?? {}; return mergeAgentNamesIntoProfiles( @@ -397,6 +410,7 @@ export function ChannelScreen({ currentPubkey, ); }, [ + agentOwnerProfilesQuery.data?.profiles, currentProfile, currentPubkey, managedAgents, diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index bedfb156e4..9a27aefe1c 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -512,3 +512,155 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => { ); } }); + +// --- agentOwner: duplicate-agent-name disambiguation --------------------- + +const OWNER_PUBKEY = + "3333333333333333333333333333333333333333333333333333333333333333"; +const AGENT_PUBKEY_A = + "4444444444444444444444444444444444444444444444444444444444444444"; +const AGENT_PUBKEY_B = + "5555555555555555555555555555555555555555555555555555555555555555"; + +function botMember(pubkey, displayName) { + return { + pubkey, + role: "bot", + isAgent: true, + joinedAt: "2026-01-01T00:00:00Z", + displayName, + }; +} + +function agentProfiles() { + return { + [AGENT_PUBKEY_A]: { + displayName: "Bart", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: OWNER_PUBKEY, + isAgent: true, + }, + [AGENT_PUBKEY_B]: { + displayName: "Bart", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: PUBKEY_B, + isAgent: true, + }, + [OWNER_PUBKEY]: { + displayName: "Taylor", + avatarUrl: "https://example.com/taylor.png", + nip05Handle: null, + ownerPubkey: null, + }, + }; +} + +test("agentOwner set when two agents share a display name", () => { + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: AGENT_PUBKEY_A })], + null, + undefined, + null, + agentProfiles(), + [botMember(AGENT_PUBKEY_A, "Bart"), botMember(AGENT_PUBKEY_B, "Bart")], + ); + assert.deepEqual(message.agentOwner, { + pubkey: OWNER_PUBKEY, + displayName: "Taylor", + avatarUrl: "https://example.com/taylor.png", + }); +}); + +test("agentOwner omitted for a uniquely named agent", () => { + const profiles = agentProfiles(); + profiles[AGENT_PUBKEY_B] = { + ...profiles[AGENT_PUBKEY_B], + displayName: "Lisa", + }; + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: AGENT_PUBKEY_A })], + null, + undefined, + null, + profiles, + [botMember(AGENT_PUBKEY_A, "Bart"), botMember(AGENT_PUBKEY_B, "Lisa")], + ); + assert.equal(message.agentOwner, undefined); +}); + +test("agentOwner omitted for human authors even on name collision", () => { + const profiles = agentProfiles(); + profiles[PUBKEY_A] = { + displayName: "Bart", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }; + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: PUBKEY_A })], + null, + undefined, + null, + profiles, + [ + { ...botMember(PUBKEY_A, "Bart"), role: "member", isAgent: false }, + botMember(AGENT_PUBKEY_A, "Bart"), + botMember(AGENT_PUBKEY_B, "Bart"), + ], + ); + assert.equal(message.agentOwner, undefined); +}); + +test("agentOwner omitted when the agent has no ownerPubkey", () => { + const profiles = agentProfiles(); + profiles[AGENT_PUBKEY_A] = { + ...profiles[AGENT_PUBKEY_A], + ownerPubkey: null, + }; + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: AGENT_PUBKEY_A })], + null, + undefined, + null, + profiles, + [botMember(AGENT_PUBKEY_A, "Bart"), botMember(AGENT_PUBKEY_B, "Bart")], + ); + assert.equal(message.agentOwner, undefined); +}); + +test("agentOwner falls back to null profile fields for unknown owner", () => { + const profiles = agentProfiles(); + delete profiles[OWNER_PUBKEY]; + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: AGENT_PUBKEY_A })], + null, + undefined, + null, + profiles, + [botMember(AGENT_PUBKEY_A, "Bart"), botMember(AGENT_PUBKEY_B, "Bart")], + ); + assert.deepEqual(message.agentOwner, { + pubkey: OWNER_PUBKEY, + displayName: null, + avatarUrl: null, + }); +}); + +test("agent name matching is case-insensitive", () => { + const profiles = agentProfiles(); + profiles[AGENT_PUBKEY_B] = { + ...profiles[AGENT_PUBKEY_B], + displayName: "bart", + }; + const [message] = formatTimelineMessages( + [streamMessage({ pubkey: AGENT_PUBKEY_A })], + null, + undefined, + null, + profiles, + [botMember(AGENT_PUBKEY_A, "Bart"), botMember(AGENT_PUBKEY_B, "bart")], + ); + assert.equal(message.agentOwner?.pubkey, OWNER_PUBKEY); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 1a2018c923..7982129c2a 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -196,6 +196,30 @@ export function formatTimelineMessages( roleByPubkey.set(member.pubkey.toLowerCase(), member.role); } } + // Agent display names that appear more than once among this room's agent + // members. Messages from these agents get an inline owner avatar so humans + // can tell same-named clones apart. + const duplicateAgentNames = (() => { + const counts = new Map(); + for (const member of members ?? []) { + if (!member.isAgent && member.role !== "bot") { + continue; + } + const name = ( + profiles?.[member.pubkey.toLowerCase()]?.displayName?.trim() || + member.displayName?.trim() || + "" + ).toLowerCase(); + if (!name) { + continue; + } + counts.set(name, (counts.get(name) ?? 0) + 1); + } + return new Set( + [...counts].filter(([, count]) => count > 1).map(([name]) => name), + ); + })(); + const deletedEventIds = new Set(); for (const event of events) { // Both kind:5 and kind:9005 are deletion markers; mirror the relay. @@ -412,6 +436,26 @@ export function formatTimelineMessages( const thread = getThreadReference(event.tags); const edit = editsByTargetId.get(event.id); const role = roleByPubkey.get(authorPubkey.toLowerCase()); + const authorProfile = profiles?.[authorPubkey.toLowerCase()]; + const agentOwner = (() => { + const isAgentAuthor = role === "bot" || authorProfile?.isAgent === true; + if ( + !isAgentAuthor || + !duplicateAgentNames.has(author.trim().toLowerCase()) + ) { + return undefined; + } + const ownerPubkey = authorProfile?.ownerPubkey; + if (!ownerPubkey) { + return undefined; + } + const ownerProfile = profiles?.[ownerPubkey.toLowerCase()]; + return { + pubkey: ownerPubkey, + displayName: ownerProfile?.displayName ?? null, + avatarUrl: ownerProfile?.avatarUrl ?? null, + }; + })(); return { id: event.id, renderKey: event.localKey ?? event.id, @@ -429,6 +473,7 @@ export function formatTimelineMessages( role === "bot" ? personaLookup?.get(authorPubkey.toLowerCase()) : undefined, + agentOwner, respondTo: role === "bot" ? respondToLookup?.get(authorPubkey.toLowerCase()) diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b98b1f249b..0997d8a5cd 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -22,6 +22,15 @@ export type TimelineMessage = { role?: string; /** For bot messages, the display name of the persona this bot was created from. */ personaDisplayName?: string; + /** + * For bot messages whose display name collides with another agent in the + * same room: the owner's profile, rendered inline to disambiguate clones. + */ + agentOwner?: { + pubkey: string; + displayName: string | null; + avatarUrl: string | null; + }; /** For bot messages, the respond-to mode (who can interact with this bot). */ respondTo?: "owner-only" | "allowlist" | "anyone"; time: string; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4efec8a66e..1abb9130c5 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -4,7 +4,10 @@ import type { TimelineMessage } from "@/features/messages/types"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + truncatePubkey, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { @@ -418,6 +421,39 @@ export const MessageRow = React.memo( {message.author} ); + // "Username's {agentname}." — popover identity for same-named agents. + const agentOwnerLabel = message.agentOwner + ? `${ + message.agentOwner.displayName?.trim() || + truncatePubkey(message.agentOwner.pubkey) + }'s ${message.author}.` + : undefined; + + // Inline owner avatar disambiguating agents whose display name collides + // with another agent in this room. h-4 matches the header's leading-4 + // line box, so the row height stays untouched; self-center keeps it off + // the baseline (baseline-aligning a replaced box would grow the line). + const agentOwnerAvatarNode = message.agentOwner ? ( + + + + + + + {agentOwnerLabel} + + ) : null; + const actionBarNode = (