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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-02 01:13:24 -07:00
committed by npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent c48006fc3e
commit 3488713763
10 changed files with 463 additions and 5 deletions

View File

@@ -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"],

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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<string, number>();
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<string>();
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())

View File

@@ -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;

View File

@@ -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(
<MessageAuthorText as="h3">{message.author}</MessageAuthorText>
);
// "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 ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex shrink-0 self-center"
data-testid="message-agent-owner-avatar"
>
<UserAvatar
avatarUrl={message.agentOwner.avatarUrl}
displayName={
message.agentOwner.displayName?.trim() ||
truncatePubkey(message.agentOwner.pubkey)
}
size="2xs"
/>
</span>
</TooltipTrigger>
<TooltipContent>{agentOwnerLabel}</TooltipContent>
</Tooltip>
) : null;
const actionBarNode = (
<div
className={cn(
@@ -499,6 +535,7 @@ export const MessageRow = React.memo(
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
displayNameOverride={agentOwnerLabel}
>
<button
className="truncate rounded leading-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
@@ -510,6 +547,7 @@ export const MessageRow = React.memo(
) : (
authorNode
)}
{agentOwnerAvatarNode}
{inlineMetadataNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
@@ -785,6 +823,12 @@ export const MessageRow = React.memo(
prev.message.tags === next.message.tags &&
prev.message.role === next.message.role &&
prev.message.personaDisplayName === next.message.personaDisplayName &&
// Field-wise: agentOwner is rebuilt each formatter pass, so reference
// equality would defeat the memo for disambiguated rows.
prev.message.agentOwner?.pubkey === next.message.agentOwner?.pubkey &&
prev.message.agentOwner?.displayName ===
next.message.agentOwner?.displayName &&
prev.message.agentOwner?.avatarUrl === next.message.agentOwner?.avatarUrl &&
prev.agentPubkeys === next.agentPubkeys &&
prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&

View File

@@ -418,3 +418,27 @@ export function useUpdateProfileMutation() {
},
});
}
/**
* Second-level profile fetch for agent owners: agents declare a NIP-OA
* `ownerPubkey`, but the owner may be neither an author nor a member of the
* room, so the first users batch misses them. Their profile feeds the inline
* owner avatar that disambiguates same-named agents.
*/
export function useAgentOwnerProfilesQuery(
profiles: UsersBatchResponse["profiles"] | undefined,
) {
const ownerPubkeys = React.useMemo(() => {
const pubkeys = new Set<string>();
for (const profile of Object.values(profiles ?? {})) {
if (profile.ownerPubkey) {
pubkeys.add(profile.ownerPubkey.toLowerCase());
}
}
return [...pubkeys];
}, [profiles]);
return useUsersBatchQuery(ownerPubkeys, {
enabled: ownerPubkeys.length > 0,
});
}

View File

@@ -58,6 +58,11 @@ type UserProfilePopoverProps = {
role?: string;
/** Value used to generate the BotIdenticon glyph (typically the author name). */
botIdenticonValue?: string;
/**
* Replaces the resolved display name in the popover header. Used to
* disambiguate same-named agents ("Username's {agentname}.").
*/
displayNameOverride?: string;
};
const HOVER_OPEN_DELAY_MS = 500;
@@ -169,6 +174,7 @@ export function UserProfilePopover({
enableProfilePanel = true,
role,
botIdenticonValue,
displayNameOverride,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<
@@ -222,7 +228,8 @@ export function UserProfilePopover({
relayAgentsQuery.isPending ||
managedAgentsQuery.isPending ||
usersBatchQuery.isPending);
const displayName = profile?.displayName ?? truncatePubkey(pubkey);
const displayName =
displayNameOverride ?? profile?.displayName ?? truncatePubkey(pubkey);
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
// wrongly hides the affordance from them — and gating on bot-ness alone shows

View File

@@ -6,9 +6,10 @@ import { getInitials } from "@/shared/lib/initials";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar";
type UserAvatarSize = "xs" | "sm" | "md";
type UserAvatarSize = "2xs" | "xs" | "sm" | "md";
const sizeClasses: Record<UserAvatarSize, string> = {
"2xs": "h-4 w-4 text-3xs",
xs: "h-5 w-5 text-3xs",
sm: "h-6 w-6 text-2xs",
md: "h-9 w-9 text-xs",

View File

@@ -0,0 +1,161 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/duplicate-agent-avatars";
// Two managed agents sharing the display name "Scout", owned by different
// humans (alice and bob), plus a uniquely named control agent. The inline
// owner avatar must appear only on the duplicate-named agents' rows.
const SCOUT_A_PUBKEY =
"aaaa000000000000000000000000000000000000000000000000000000000001";
const SCOUT_B_PUBKEY =
"aaaa000000000000000000000000000000000000000000000000000000000002";
const SOLO_PUBKEY =
"aaaa000000000000000000000000000000000000000000000000000000000003";
async function emitMockMessage(page: Page, content: string, pubkey: string) {
const event = await page.evaluate(
({ msg, pk }) => {
return (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
pubkey?: string;
}) => { id: string };
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: msg,
pubkey: pk,
});
},
{ msg: content, pk: pubkey },
);
if (!event) {
throw new Error("Mock message emitter is not installed");
}
return event;
}
async function waitForMockLiveSubscription(page: Page) {
await expect
.poll(async () =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
}) ?? false,
),
)
.toBe(true);
}
async function waitForTimelineSettled(page: Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
test("duplicate-named agents get an inline owner avatar; unique agents do not", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: SCOUT_A_PUBKEY,
name: "Scout",
status: "running",
channelNames: ["general"],
},
{
pubkey: SCOUT_B_PUBKEY,
name: "Scout",
status: "running",
channelNames: ["general"],
},
{
pubkey: SOLO_PUBKEY,
name: "Ranger",
status: "running",
channelNames: ["general"],
},
],
// Seeded after managed agents, so these override the default
// owner (the mock viewer) with two distinct humans.
searchProfiles: [
{
pubkey: SCOUT_A_PUBKEY,
displayName: "Scout",
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
isAgent: true,
},
{
pubkey: SCOUT_B_PUBKEY,
displayName: "Scout",
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
isAgent: true,
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page);
await emitMockMessage(page, "Scout A reporting in.", SCOUT_A_PUBKEY);
await emitMockMessage(page, "Scout B reporting in.", SCOUT_B_PUBKEY);
await emitMockMessage(page, "Ranger here, no twin.", SOLO_PUBKEY);
await waitForTimelineSettled(page);
const scoutARow = page
.getByTestId("message-row")
.filter({ hasText: "Scout A reporting in." });
const scoutBRow = page
.getByTestId("message-row")
.filter({ hasText: "Scout B reporting in." });
const rangerRow = page
.getByTestId("message-row")
.filter({ hasText: "Ranger here, no twin." });
// Both same-named agents carry the inline owner avatar…
await expect(
scoutARow.getByTestId("message-agent-owner-avatar"),
).toBeVisible();
await expect(
scoutBRow.getByTestId("message-agent-owner-avatar"),
).toBeVisible();
// …the uniquely named agent does not.
await expect(rangerRow.getByTestId("message-agent-owner-avatar")).toHaveCount(
0,
);
// The disambiguated row must not be taller than the plain agent row.
const scoutBox = await scoutARow.boundingBox();
const rangerBox = await rangerRow.boundingBox();
if (!scoutBox || !rangerBox) {
throw new Error("Expected both message rows to be visible.");
}
expect(scoutBox.height).toBe(rangerBox.height);
await page.screenshot({
path: `${SHOTS}/01-timeline-duplicate-agents.png`,
fullPage: false,
});
// Hovering the agent's name opens the balloon with the possessive identity.
await scoutARow.getByTestId("message-author").first().hover();
const popover = page.getByTestId("user-profile-popover");
await expect(popover).toBeVisible();
await expect(popover.getByText("alice's Scout.")).toBeVisible();
await page.screenshot({
path: `${SHOTS}/02-balloon-owners-scout.png`,
fullPage: false,
});
});