fix(desktop): simplify duplicate agent provenance (#6401)

## Summary

- leave the local same-name agent row unlabelled because locality is
implied
- mark only the remote identity with a compact cloud badge reading
`Other setup` in mention autocomplete and Channel members
- suppress the redundant `managed by you` text for duplicate owned
agents while retaining distinct short npubs and exact-pubkey routing

## Testing

- Desktop E2E build/typecheck: `pnpm build:e2e`
- focused Playwright smoke test: `duplicate owned agents preserve
provenance and exact pubkey selection` (1 passed)
  - visible provenance in autocomplete and Channel members
  - local identity remains unmarked
  - keyboard selection routes each exact pubkey
  - narrow 760×640 viewport containment
- focused Biome check on the changed marker and E2E files
- `pnpm check:px-text`
- `pnpm check:pubkey-truncation`
- `just file-size-check`
- `git diff --check`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-21 10:05:45 -06:00
committed by GitHub
parent b56a52ca11
commit aea0ef8df9
14 changed files with 398 additions and 139 deletions

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isOtherSetupAgent } from "./otherSetupAgent.ts";
const OWNER = "a".repeat(64);
const AGENT = "b".repeat(64);
test("fails closed while the local managed directory is unresolved", () => {
assert.equal(
isOtherSetupAgent({
agentDirectoriesReady: false,
currentPubkey: OWNER,
managedAgents: [],
profileOwnerPubkey: OWNER,
pubkey: AGENT,
relayAgents: [],
}),
false,
);
});
test("labels a viewer-owned non-local identity as another setup", () => {
assert.equal(
isOtherSetupAgent({
agentDirectoriesReady: true,
currentPubkey: OWNER,
managedAgents: [],
profileOwnerPubkey: OWNER,
pubkey: AGENT,
relayAgents: [],
}),
true,
);
});

View File

@@ -0,0 +1,39 @@
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function isOtherSetupAgent({
agentDirectoriesReady,
currentPubkey,
managedAgents,
profileOwnerPubkey,
pubkey,
relayAgents,
}: {
agentDirectoriesReady: boolean;
currentPubkey?: string;
managedAgents: readonly ManagedAgent[];
profileOwnerPubkey?: string | null;
pubkey: string;
relayAgents: readonly RelayAgent[];
}): boolean {
if (!agentDirectoriesReady || !currentPubkey) return false;
const normalizedPubkey = normalizePubkey(pubkey);
if (
managedAgents.some(
(agent) => normalizePubkey(agent.pubkey) === normalizedPubkey,
)
) {
return false;
}
const relayOwnerPubkey = relayAgents.find(
(agent) => normalizePubkey(agent.pubkey) === normalizedPubkey,
)?.ownerPubkey;
const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey;
return Boolean(
ownerPubkey &&
normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey),
);
}

View File

@@ -0,0 +1,30 @@
import { Cloud } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
const OTHER_SETUP_LABEL = "From another Buzz setup";
export function OtherSetupAgentMarker({
className,
testId,
}: {
className?: string;
testId?: string;
}) {
return (
<Badge
aria-label={OTHER_SETUP_LABEL}
className={cn(
"gap-1 px-1.5 py-0.5 font-medium normal-case tracking-normal",
className,
)}
data-testid={testId}
title={OTHER_SETUP_LABEL}
variant="secondary"
>
<Cloud aria-hidden="true" className="h-3 w-3" />
Other setup
</Badge>
);
}

View File

@@ -0,0 +1,92 @@
import { Bot } from "lucide-react";
import type { UserSearchResult } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MEMBER_ROW_INSET_DIVIDER_CLASS =
"after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden";
export function formatAddCandidateName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncatePubkey(user.pubkey)
);
}
export function AddMemberSearchResultRow({
disabled,
onSelect,
ownerLabel,
user,
}: {
disabled: boolean;
onSelect: (user: UserSearchResult) => void;
ownerLabel?: string | null;
user: UserSearchResult;
}) {
return (
<div
className={cn(
"group/add-result relative isolate flex min-h-14 w-full items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-within:bg-muted/40",
MEMBER_ROW_INSET_DIVIDER_CLASS,
)}
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
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)}
type="button"
/>
<UserAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
displayName={formatAddCandidateName(user)}
size="sm"
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{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>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-4 w-4" />
agent
</span>
</div>
<span className="block truncate font-mono text-2xs text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
{ownerLabel ? (
<span className="block truncate text-xs text-muted-foreground">
managed by {ownerLabel}
</span>
) : null}
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
</span>
)}
</div>
<Button
className="relative z-20 shrink-0"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelect(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bot, UserRoundPlus, X } from "lucide-react";
import { UserRoundPlus, X } from "lucide-react";
import {
invalidateChannelState,
useAddChannelMembersMutation,
@@ -14,6 +14,7 @@ import {
getSharedChannelIds,
isAgentIdentityInAllowedList,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
import { formatMemberName } from "@/features/channels/lib/memberUtils";
@@ -40,7 +41,6 @@ import type {
ManagedAgent,
UserSearchResult,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogClose,
@@ -50,13 +50,15 @@ import {
} from "@/shared/ui/dialog";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
MODAL_SEARCH_INPUT_CLASS,
MODAL_SEARCH_SHELL_CLASS,
} from "@/shared/ui/modalSearchStyles";
import {
AddMemberSearchResultRow,
formatAddCandidateName,
} from "./AddMemberSearchResultRow";
import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard";
import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks";
import {
@@ -68,16 +70,6 @@ import { useMembersSidebarActions } from "./useMembersSidebarActions";
import { useMembersSidebarModeration } from "./useMembersSidebarModeration";
const MEMBER_ADD_RESULT_LIMIT = 50;
const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2;
const MEMBER_ROW_INSET_DIVIDER_CLASS =
"after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden";
function formatAddCandidateName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncatePubkey(user.pubkey)
);
}
type AddMemberSearchCandidate = UserSearchResult & {
isManagedAgent?: boolean;
isMember?: boolean;
@@ -193,6 +185,13 @@ export function MembersSidebar({
managedAgentsQuery,
relayAgentsQuery,
} = useClassifiedMembers(rawMembers, currentPubkey);
const agentDirectoriesReady =
managedAgentsQuery.data !== undefined &&
managedAgentsQuery.error === null &&
!managedAgentsQuery.isFetching &&
relayAgentsQuery.data !== undefined &&
relayAgentsQuery.error === null &&
!relayAgentsQuery.isFetching;
const activeMembers = React.useMemo(
() =>
[...people, ...bots].sort((left, right) =>
@@ -611,6 +610,16 @@ export function MembersSidebar({
const managedAgent = memberIsBot
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
: undefined;
const showOtherSetupMarker =
memberIsBot &&
isOtherSetupAgent({
agentDirectoriesReady,
currentPubkey,
managedAgents: managedAgentsQuery.data ?? [],
profileOwnerPubkey: memberProfile?.ownerPubkey,
pubkey: member.pubkey,
relayAgents: relayAgentsQuery.data ?? [],
});
const managedAgentRuntime =
memberIsBot && relayUrl
? findManagedAgentRuntime(
@@ -675,6 +684,7 @@ export function MembersSidebar({
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
profileAvatarUrl={memberProfile?.avatarUrl ?? null}
showOtherSetupMarker={showOtherSetupMarker}
viewerIsOwner={viewerIsOwner}
/>
</div>
@@ -913,78 +923,3 @@ function SearchResultSectionTitle({
</div>
);
}
function AddMemberSearchResultRow({
disabled,
onSelect,
ownerLabel,
user,
}: {
disabled: boolean;
onSelect: (user: UserSearchResult) => void;
ownerLabel?: string | null;
user: UserSearchResult;
}) {
return (
<div
className={cn(
"group/add-result relative isolate flex min-h-14 w-full items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-within:bg-muted/40",
MEMBER_ROW_INSET_DIVIDER_CLASS,
)}
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
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)}
type="button"
/>
<UserAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
displayName={formatAddCandidateName(user)}
size="sm"
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{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>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-4 w-4" />
agent
</span>
</div>
<span className="block truncate font-mono text-2xs text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
{ownerLabel ? (
<span className="block truncate text-xs text-muted-foreground">
managed by {ownerLabel}
</span>
) : null}
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
</span>
)}
</div>
<Button
className="relative z-20 shrink-0"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelect(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import {
getManagedAgentPrimaryActionLabel,
isManagedAgentActive,
} from "@/features/agents/lib/managedAgentControlActions";
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import {
@@ -73,6 +74,7 @@ type MembersSidebarMemberCardProps = {
onViewActivity?: (pubkey: string) => void;
presenceStatus?: PresenceStatus | null;
profileAvatarUrl?: string | null;
showOtherSetupMarker?: boolean;
viewerIsOwner: boolean;
};
@@ -141,6 +143,7 @@ export function MembersSidebarMemberCard({
onViewActivity,
presenceStatus,
profileAvatarUrl,
showOtherSetupMarker = false,
viewerIsOwner,
}: MembersSidebarMemberCardProps) {
const roleLabel = formatRoleLabel(member, memberIsBot);
@@ -177,21 +180,28 @@ export function MembersSidebarMemberCard({
</div>
<div className="min-w-0 flex-1">
{memberIsBot ? (
<div className="relative min-w-0">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/member:opacity-0 group-focus-within/member:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{memberLabel}
</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" />
{roleLabel}
<div className="flex min-w-0 items-center gap-2">
<div className="relative min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/member:opacity-0 group-focus-within/member:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{memberLabel}
</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" />
{roleLabel}
</span>
</div>
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/member:opacity-100 group-focus-within/member:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(member.pubkey)}
</span>
</span>
</div>
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/member:opacity-100 group-focus-within/member:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(member.pubkey)}
</span>
</span>
{showOtherSetupMarker ? (
<OtherSetupAgentMarker
testId={`sidebar-member-agent-provenance-${member.pubkey}`}
/>
) : null}
</div>
) : (
<div className="flex min-w-0 items-center gap-2">

View File

@@ -31,6 +31,7 @@ test("flushMentionDebounce returns the fresh suggestion with its fresh start ind
candidate({ displayName: "Beta", pubkey: "b".repeat(64) }),
],
activePersonaIds: new Set(),
agentProvenanceReady: true,
channelType: "group",
});
@@ -48,6 +49,7 @@ test("flushMentionDebounce returns no-match for a fresh query with no matches",
searchableNamesLowerRef: ref(["alpha", "beta"]),
candidates: [candidate()],
activePersonaIds: new Set(),
agentProvenanceReady: true,
channelType: "group",
});
@@ -62,6 +64,7 @@ test("flushMentionDebounce returns null for an empty fresh query", () => {
searchableNamesLowerRef: ref(["alpha", "beta"]),
candidates: [candidate()],
activePersonaIds: new Set(),
agentProvenanceReady: true,
channelType: "group",
});
@@ -99,6 +102,7 @@ test("flushMentionDebounce preserves a team expansion selected with Enter", () =
}),
],
activePersonaIds: new Set(),
agentProvenanceReady: true,
channelType: "group",
});

View File

@@ -37,6 +37,7 @@ export function flushMentionDebounce<T extends MentionCandidateWithUI>(opts: {
searchableNamesLowerRef: React.RefObject<string[]>;
candidates: readonly T[];
activePersonaIds: ReadonlySet<string>;
agentProvenanceReady: boolean;
channelType?: ChannelType | null;
currentPubkey?: string | null;
ownerProfiles?: UserProfileLookup;
@@ -72,6 +73,7 @@ export function flushMentionDebounce<T extends MentionCandidateWithUI>(opts: {
return {
type: "match",
suggestion: mapMentionCandidateToSuggestion({
agentProvenanceReady: opts.agentProvenanceReady,
candidate,
label,
channelType: opts.channelType,

View File

@@ -16,8 +16,9 @@ function candidate(overrides = {}) {
};
}
function suggestion(overrides = {}) {
function suggestion(overrides = {}, agentProvenanceReady = true) {
return mapMentionCandidateToSuggestion({
agentProvenanceReady,
candidate: candidate(overrides),
currentPubkey: OWNER,
label: "Carl",
@@ -35,6 +36,14 @@ test("labels same-owner relay agent identities as managed elsewhere", () => {
assert.equal(suggestion().agentProvenance, "managed-elsewhere");
});
test("fails closed while the managed-agent directory is unresolved", () => {
assert.equal(
suggestion({ isManagedAgent: true }, false).agentProvenance,
undefined,
);
assert.equal(suggestion({}, false).agentProvenance, undefined);
});
test("does not attribute another owner's agent to a device", () => {
assert.equal(
suggestion({ ownerPubkey: "c".repeat(64) }).agentProvenance,

View File

@@ -24,10 +24,12 @@ export function mapMentionCandidateToSuggestion(opts: {
label: string;
channelType?: ChannelType | null;
currentPubkey?: string | null;
agentProvenanceReady: boolean;
ownerProfiles?: UserProfileLookup;
profiles?: UserProfileLookup;
}): MentionSuggestion {
const {
agentProvenanceReady,
candidate,
channelType,
currentPubkey,
@@ -54,7 +56,7 @@ export function mapMentionCandidateToSuggestion(opts: {
null,
isAgent: candidate.isAgent,
agentProvenance:
candidate.kind === "identity" && candidate.isAgent
agentProvenanceReady && candidate.kind === "identity" && candidate.isAgent
? candidate.isManagedAgent
? "managed-here"
: candidate.ownerPubkey &&

View File

@@ -526,6 +526,7 @@ export function useMentions(
.slice(0, MENTION_SUGGESTION_LIMIT)
.map(({ candidate, label }) =>
mapMentionCandidateToSuggestion({
agentProvenanceReady: agentDirectoriesReady,
candidate,
label,
channelType: options?.channelType,
@@ -536,6 +537,7 @@ export function useMentions(
);
}, [
activePersonaIds,
agentDirectoriesReady,
currentPubkey,
mentionCandidatesWithTeams,
mentionQuery,
@@ -913,6 +915,7 @@ export function useMentions(
searchableNamesLowerRef,
candidates: mentionCandidatesWithTeams,
activePersonaIds,
agentProvenanceReady: agentDirectoriesReady,
channelType: options?.channelType,
currentPubkey,
ownerProfiles: ownerProfilesQuery.data?.profiles,
@@ -942,6 +945,7 @@ export function useMentions(
},
[
activePersonaIds,
agentDirectoriesReady,
cancelMentionAutocomplete,
currentPubkey,
isMentionOpen,

View File

@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mentionAgentLabel } from "./MentionAutocomplete.tsx";
import { showMentionAgentProvenanceMarker } from "./MentionAutocomplete.tsx";
function suggestion(agentProvenance) {
return {
@@ -12,21 +12,27 @@ function suggestion(agentProvenance) {
};
}
test("duplicate owned agents show their management provenance", () => {
test("duplicate owned agents mark only the other setup", () => {
assert.equal(
mentionAgentLabel(suggestion("managed-here"), true),
"agent · managed here",
showMentionAgentProvenanceMarker(suggestion("managed-here"), true),
false,
);
assert.equal(
mentionAgentLabel(suggestion("managed-elsewhere"), true),
"agent · managed elsewhere",
showMentionAgentProvenanceMarker(suggestion("managed-elsewhere"), true),
true,
);
});
test("unique agents keep the compact generic label", () => {
assert.equal(mentionAgentLabel(suggestion("managed-here"), false), "agent");
test("unique agents omit management provenance", () => {
assert.equal(
showMentionAgentProvenanceMarker(suggestion("managed-here"), false),
false,
);
});
test("agents without trustworthy provenance keep the generic label", () => {
assert.equal(mentionAgentLabel(suggestion(undefined), true), "agent");
test("agents without trustworthy provenance omit management provenance", () => {
assert.equal(
showMentionAgentProvenanceMarker(suggestion(undefined), true),
false,
);
});

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { Bot, Users } from "lucide-react";
import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates";
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
@@ -36,14 +37,11 @@ type MentionAutocompleteProps = {
position?: "above" | "below";
};
export function mentionAgentLabel(
export function showMentionAgentProvenanceMarker(
suggestion: MentionSuggestion,
hasNameCollision: boolean,
) {
if (!hasNameCollision || !suggestion.agentProvenance) return "agent";
return suggestion.agentProvenance === "managed-here"
? "agent · managed here"
: "agent · managed elsewhere";
): boolean {
return hasNameCollision && suggestion.agentProvenance === "managed-elsewhere";
}
export const MentionAutocomplete = React.memo(function MentionAutocomplete({
@@ -113,7 +111,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestion.displayName;
const hasNameCollision =
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
const agentLabel = mentionAgentLabel(suggestion, hasNameCollision);
const showAgentProvenanceMarker = showMentionAgentProvenanceMarker(
suggestion,
hasNameCollision,
);
const ownerLabel =
hasNameCollision && suggestion.agentProvenance
? null
: suggestion.ownerLabel;
const collisionNpub =
hasNameCollision && suggestion.pubkey
? safeNpub(suggestion.pubkey)
@@ -158,7 +163,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
{suggestion.kind === "team" ||
suggestion.isAgent ||
suggestion.role ||
suggestion.ownerLabel ||
ownerLabel ||
suggestion.notInChannel ? (
<span
className={cn(
@@ -180,7 +185,10 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
className="h-3.5 w-3.5"
data-testid="mention-agent-icon"
/>
{agentLabel}
agent
{showAgentProvenanceMarker ? (
<OtherSetupAgentMarker testId="mention-agent-provenance" />
) : null}
</span>
) : suggestion.role ? (
<Badge
@@ -190,21 +198,21 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
{suggestion.role}
</Badge>
) : null}
{suggestion.ownerLabel || suggestion.notInChannel ? (
{ownerLabel || suggestion.notInChannel ? (
<span
className="min-w-0 truncate"
title={
suggestion.ownerLabel && suggestion.notInChannel
? `managed by ${suggestion.ownerLabel} · not in channel`
: suggestion.ownerLabel
? `managed by ${suggestion.ownerLabel}`
ownerLabel && suggestion.notInChannel
? `managed by ${ownerLabel} · not in channel`
: ownerLabel
? `managed by ${ownerLabel}`
: "not in channel"
}
>
{suggestion.ownerLabel && suggestion.notInChannel
? `managed by ${suggestion.ownerLabel} · not in channel`
: suggestion.ownerLabel
? `managed by ${suggestion.ownerLabel}`
{ownerLabel && suggestion.notInChannel
? `managed by ${ownerLabel} · not in channel`
: ownerLabel
? `managed by ${ownerLabel}`
: "not in channel"}
</span>
) : null}

View File

@@ -374,8 +374,31 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
`mention-suggestion-${managedPubkey}`,
);
const relayRow = dropdown.getByTestId(`mention-suggestion-${relayPubkey}`);
await expect(managedRow).toContainText("agent · managed here");
await expect(relayRow).toContainText("agent · managed elsewhere");
await expect(managedRow).toContainText("agent");
await expect(managedRow.getByTestId("mention-agent-provenance")).toHaveCount(
0,
);
await expect(relayRow).toContainText("agent");
await expect(
relayRow.getByTestId("mention-agent-provenance"),
).toHaveAttribute("aria-label", "From another Buzz setup");
await expect(
relayRow.getByText("Other setup", { exact: true }),
).toBeVisible();
await expect(managedRow).not.toContainText("managed by you");
await expect(relayRow).not.toContainText("managed by you");
await page.setViewportSize({ width: 760, height: 640 });
await expect(
relayRow.getByText("Other setup", { exact: true }),
).toBeVisible();
const rowBox = await relayRow.boundingBox();
const dropdownBox = await dropdown.boundingBox();
expect(rowBox).not.toBeNull();
expect(dropdownBox).not.toBeNull();
expect((rowBox?.x ?? 0) + (rowBox?.width ?? 0)).toBeLessThanOrEqual(
(dropdownBox?.x ?? 0) + (dropdownBox?.width ?? 0) + 1,
);
const collisionKeys = dropdown.getByTestId("mention-collision-npub");
await expect(collisionKeys).toHaveCount(2);
@@ -399,7 +422,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
await input.press("ArrowDown");
}
await input.press("Enter");
await page.keyboard.type("local");
await input.fill("@carl local");
await page.getByTestId("send-message").click();
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@carl local"))
@@ -409,10 +432,21 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
await input.fill("@carl");
const reopenedDropdown = autocomplete(page);
await expect(reopenedDropdown).toBeVisible();
await reopenedDropdown
.getByTestId(`mention-suggestion-${relayPubkey}`)
.click();
await page.keyboard.type("remote");
const reopenedRows = reopenedDropdown.locator("button");
const relayIndex = await reopenedRows.evaluateAll(
(buttons, pubkey) =>
buttons.findIndex(
(button) =>
button.getAttribute("data-testid") === `mention-suggestion-${pubkey}`,
),
relayPubkey,
);
expect(relayIndex).toBeGreaterThanOrEqual(0);
for (let index = 0; index < relayIndex; index += 1) {
await input.press("ArrowDown");
}
await input.press("Enter");
await input.fill("@carl remote");
await page.getByTestId("send-message").click();
const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" });
try {
@@ -424,6 +458,55 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@carl remote"))
.toEqual([relayPubkey]);
await page.getByTestId("channel-members-trigger").click();
const remoteSidebarMarker = page.getByTestId(
`sidebar-member-agent-provenance-${relayPubkey}`,
);
await expect(
page.getByTestId(`sidebar-member-agent-provenance-${managedPubkey}`),
).toHaveCount(0);
await expect(remoteSidebarMarker).toHaveAttribute(
"aria-label",
"From another Buzz setup",
);
await expect(remoteSidebarMarker).toHaveText("Other setup");
const remoteSidebarRow = page.getByTestId(`sidebar-member-${relayPubkey}`);
const localSidebarMarker = page.getByTestId(
`sidebar-member-agent-provenance-${managedPubkey}`,
);
const markerRemainsVisible = () =>
remoteSidebarMarker.evaluate((marker) => {
let element: Element | null = marker;
while (element) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
if (Number(style.opacity) === 0) return false;
element = element.parentElement;
}
return true;
});
await expect.poll(markerRemainsVisible).toBe(true);
await expect(localSidebarMarker).toHaveCount(0);
const sidebarMarkerBox = await remoteSidebarMarker.boundingBox();
const sidebarBox = await page.getByTestId("members-sidebar").boundingBox();
expect(sidebarMarkerBox).not.toBeNull();
expect(sidebarBox).not.toBeNull();
expect(
(sidebarMarkerBox?.x ?? 0) + (sidebarMarkerBox?.width ?? 0),
).toBeLessThanOrEqual((sidebarBox?.x ?? 0) + (sidebarBox?.width ?? 0) + 1);
await remoteSidebarRow.hover();
await page.waitForTimeout(200);
await expect.poll(markerRemainsVisible).toBe(true);
await expect(localSidebarMarker).toHaveCount(0);
await page.getByTestId(`sidebar-member-open-profile-${relayPubkey}`).focus();
await page.waitForTimeout(200);
await expect.poll(markerRemainsVisible).toBe(true);
await expect(localSidebarMarker).toHaveCount(0);
});
test("relay-only shared agents emit an outbound mention tag when selected", async ({