mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
35
desktop/src/features/agents/lib/otherSetupAgent.test.mjs
Normal file
35
desktop/src/features/agents/lib/otherSetupAgent.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
39
desktop/src/features/agents/lib/otherSetupAgent.ts
Normal file
39
desktop/src/features/agents/lib/otherSetupAgent.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
30
desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx
Normal file
30
desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user