mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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:
committed by
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
c48006fc3e
commit
3488713763
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
161
desktop/tests/e2e/duplicate-agent-avatars.spec.ts
Normal file
161
desktop/tests/e2e/duplicate-agent-avatars.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user