diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2eddb4e103..09dac97d70 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -1,6 +1,5 @@ -use std::{collections::BTreeMap, path::PathBuf, process::Child}; - use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::PathBuf, process::Child}; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -80,8 +79,9 @@ pub struct RelayAgentInfo { pub status: String, #[serde(default)] pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Vec, } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManagedAgentRecord { pub pubkey: String, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 0ef035f48f..0f416495d3 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -940,6 +940,26 @@ mod tests { ); } + #[test] + fn agents_preserves_allowlist_metadata_for_directory_parse() { + let e = ev( + 10100, + r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + vec![], + ); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); + } + #[test] fn relay_members_dedupes_and_defaults_role() { let pk1 = "a".repeat(64); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs new file mode 100644 index 0000000000..3aa2cd9d9d --- /dev/null +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, + relayAgentIsSharedWithUser, +} from "./agentAutocompleteEligibility.ts"; + +const CURRENT_PUBKEY = "a".repeat(64); +const OWNER_PUBKEY = "b".repeat(64); +const OTHER_OWNER_PUBKEY = "c".repeat(64); +const PUB_A = "1".repeat(64); +const PUB_B = "2".repeat(64); +const PUB_C = "3".repeat(64); +const PUB_D = "4".repeat(64); + +function coalesce(candidates, options = {}) { + return coalesceAgentAutocompleteCandidates(candidates, { + currentPubkey: CURRENT_PUBKEY, + getLabel: (candidate) => candidate.displayName, + ...options, + }); +} + +function makeAgent(overrides = {}) { + return { + pubkey: PUB_A, + displayName: "Pinky", + isAgent: true, + isMember: false, + ...overrides, + }; +} + +test("getSharedChannelIds: includes only active joined channels", () => { + assert.deepEqual( + getSharedChannelIds([ + { id: "joined", isMember: true, archivedAt: null }, + { id: "not-joined", isMember: false, archivedAt: null }, + { id: "archived", isMember: true, archivedAt: "2026-01-01T00:00:00Z" }, + ]), + new Set(["joined"]), + ); +}); + +test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unshared ones", () => { + const sharedChannelIds = new Set(["general"]); + + assert.equal( + relayAgentIsSharedWithUser( + { respondTo: "anyone", respondToAllowlist: [], channelIds: ["general"] }, + sharedChannelIds, + ), + true, + ); + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + sharedChannelIds, + ), + false, + ); + assert.equal( + relayAgentIsSharedWithUser( + { respondTo: "anyone", respondToAllowlist: [], channelIds: ["other"] }, + sharedChannelIds, + ), + false, + ); +}); + +test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => { + const sharedChannelIds = new Set(["general"]); + + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo: "allowlist", + respondToAllowlist: [OTHER_OWNER_PUBKEY, CURRENT_PUBKEY.toUpperCase()], + channelIds: ["other"], + }, + sharedChannelIds, + CURRENT_PUBKEY, + ), + true, + ); + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo: "allowlist", + respondToAllowlist: [OTHER_OWNER_PUBKEY], + channelIds: ["general"], + }, + sharedChannelIds, + CURRENT_PUBKEY, + ), + false, + ); +}); + +test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { + const result = getMentionableAgentPubkeys({ + managedAgentPubkeys: [PUB_A], + currentPubkey: CURRENT_PUBKEY, + relayAgents: [ + { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + }, + { + pubkey: PUB_C, + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: ["other"], + }, + { + pubkey: PUB_D, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["other"], + }, + ], + sharedChannelIds: new Set(["general"]), + }); + + assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); +}); + +test("coalesceAgentAutocompleteCandidates: merges agents with the same persona id", () => { + const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" }); + const second = makeAgent({ + pubkey: PUB_B, + personaId: "pinky", + isMember: true, + }); + + assert.deepEqual(coalesce([first, second]), [second]); +}); + +test("coalesceAgentAutocompleteCandidates: merges agents with the same owner and name", () => { + const first = makeAgent({ pubkey: PUB_A, ownerPubkey: OWNER_PUBKEY }); + const second = makeAgent({ + pubkey: PUB_B, + ownerPubkey: OWNER_PUBKEY, + isMember: true, + }); + + assert.deepEqual(coalesce([first, second]), [second]); +}); + +test("coalesceAgentAutocompleteCandidates: keeps same-name agents with different owners distinct", () => { + const first = makeAgent({ pubkey: PUB_A, ownerPubkey: OWNER_PUBKEY }); + const second = makeAgent({ + pubkey: PUB_B, + ownerPubkey: OTHER_OWNER_PUBKEY, + }); + + assert.deepEqual(coalesce([first, second]), [first, second]); +}); + +test("coalesceAgentAutocompleteCandidates: keeps owner-less same-name agents distinct", () => { + const first = makeAgent({ pubkey: PUB_A }); + const second = makeAgent({ pubkey: PUB_B }); + + assert.deepEqual(coalesce([first, second]), [first, second]); +}); + +test("coalesceAgentAutocompleteCandidates: keeps owner-less managed same-name agents distinct", () => { + const first = makeAgent({ pubkey: PUB_A, isManagedAgent: true }); + const second = makeAgent({ pubkey: PUB_B, isManagedAgent: true }); + + assert.deepEqual(coalesce([first, second]), [first, second]); +}); + +test("coalesceAgentAutocompleteCandidates: merges current-owner same-name agents", () => { + const first = makeAgent({ pubkey: PUB_A, ownerPubkey: CURRENT_PUBKEY }); + const second = makeAgent({ + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY, + isManagedAgent: true, + }); + + assert.deepEqual(coalesce([first, second]), [second]); +}); + +test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { + const first = makeAgent({ pubkey: PUB_A, isAgent: false }); + const second = makeAgent({ pubkey: PUB_B, isAgent: false }); + + assert.deepEqual(coalesce([first, second]), [first, second]); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts new file mode 100644 index 0000000000..b3a57927a6 --- /dev/null +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -0,0 +1,213 @@ +import type { Channel, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function getSharedChannelIds(channels: readonly Channel[] | undefined) { + return new Set( + (channels ?? []) + .filter((channel) => channel.isMember && channel.archivedAt === null) + .map((channel) => channel.id), + ); +} + +export function relayAgentIsSharedWithUser( + agent: Pick, + sharedChannelIds: ReadonlySet, + currentPubkey?: string | null, +) { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { + return agent.respondToAllowlist + .map((pubkey) => normalizePubkey(pubkey)) + .includes(normalizedCurrentPubkey); + } + + return ( + agent.respondTo === "anyone" && + agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) + ); +} + +export function getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, +}: { + currentPubkey?: string | null; + managedAgentPubkeys: Iterable; + relayAgents: readonly RelayAgent[] | undefined; + sharedChannelIds: ReadonlySet; +}) { + const pubkeys = new Set( + [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), + ); + + for (const agent of relayAgents ?? []) { + if (relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + } + + return pubkeys; +} + +type AgentAutocompleteCandidate = { + pubkey?: string; + displayName?: string | null; + ownerPubkey?: string | null; + isAgent?: boolean; + isManagedAgent?: boolean; + isMember?: boolean; + personaId?: string | null; +}; + +function normalizeLabel(label: string | null | undefined) { + return label?.trim().toLowerCase() || null; +} + +function agentIdentityKey( + candidate: T, + currentPubkey: string | null | undefined, + getLabel: (candidate: T) => string | null | undefined, +) { + if (candidate.isAgent !== true) { + return null; + } + + if (candidate.personaId) { + return `persona:${candidate.personaId}`; + } + + const label = normalizeLabel(getLabel(candidate)); + if (!label) { + return null; + } + + const ownerPubkey = candidate.ownerPubkey + ? normalizePubkey(candidate.ownerPubkey) + : null; + if (ownerPubkey) { + if (currentPubkey && ownerPubkey === normalizePubkey(currentPubkey)) { + return `local:name:${label}`; + } + return `owner:${ownerPubkey}:name:${label}`; + } + + return null; +} + +function agentCandidateRank( + candidate: T, + currentPubkey: string | null | undefined, + preferredPubkeys: ReadonlySet, +) { + const pubkey = candidate.pubkey ? normalizePubkey(candidate.pubkey) : null; + const ownerPubkey = candidate.ownerPubkey + ? normalizePubkey(candidate.ownerPubkey) + : null; + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + return [ + candidate.isMember === true ? 0 : 1, + pubkey && preferredPubkeys.has(pubkey) ? 0 : 1, + candidate.isManagedAgent === true ? 0 : 1, + candidate.personaId ? 0 : 1, + ownerPubkey && ownerPubkey === normalizedCurrentPubkey ? 0 : 1, + ]; +} + +function isPreferredAgentCandidate( + next: T, + current: T, + currentPubkey: string | null | undefined, + preferredPubkeys: ReadonlySet, +) { + const nextRank = agentCandidateRank(next, currentPubkey, preferredPubkeys); + const currentRank = agentCandidateRank( + current, + currentPubkey, + preferredPubkeys, + ); + + for (let index = 0; index < nextRank.length; index++) { + if (nextRank[index] !== currentRank[index]) { + return nextRank[index] < currentRank[index]; + } + } + + return false; +} + +export function coalesceAutocompleteCandidatesByKey( + candidates: readonly T[], + getKey: (candidate: T) => string | null, +) { + const output: T[] = []; + const indexesByKey = new Map(); + + for (const candidate of candidates) { + const key = getKey(candidate); + if (!key) { + output.push(candidate); + continue; + } + + if (!indexesByKey.has(key)) { + indexesByKey.set(key, output.length); + output.push(candidate); + } + } + + return output; +} + +export function coalesceAgentAutocompleteCandidates< + T extends AgentAutocompleteCandidate, +>( + candidates: readonly T[], + { + currentPubkey, + getLabel, + preferredPubkeys = new Set(), + }: { + currentPubkey?: string | null; + getLabel: (candidate: T) => string | null | undefined; + preferredPubkeys?: ReadonlySet; + }, +) { + const output: T[] = []; + const indexesByKey = new Map(); + + for (const candidate of candidates) { + const key = agentIdentityKey(candidate, currentPubkey, getLabel); + if (!key) { + output.push(candidate); + continue; + } + + const currentIndex = indexesByKey.get(key); + if (currentIndex === undefined) { + indexesByKey.set(key, output.length); + output.push(candidate); + continue; + } + + if ( + isPreferredAgentCandidate( + candidate, + output[currentIndex], + currentPubkey, + preferredPubkeys, + ) + ) { + output[currentIndex] = candidate; + } + } + + return output; +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 61efb31046..d7a147a9fd 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -4,8 +4,14 @@ import { Bot, UserRoundPlus, X } from "lucide-react"; import { useAddChannelMembersMutation, useChannelMembersQuery, + useChannelsQuery, } from "@/features/channels/hooks"; import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { + coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, +} from "@/features/agents/lib/agentAutocompleteEligibility"; import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -19,6 +25,7 @@ import { } from "@/features/profile/hooks"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { changeChannelMemberRole } from "@/shared/api/tauri"; import type { AddChannelMembersResult, @@ -61,6 +68,60 @@ function formatAddCandidateName(user: UserSearchResult) { ); } +function formatOwnerName( + user: UserSearchResult, + ownerProfiles?: Record< + string, + { displayName: string | null; nip05Handle: string | null } + >, +) { + if (!user.ownerPubkey) { + return null; + } + + const owner = ownerProfiles?.[normalizePubkey(user.ownerPubkey)]; + return ( + owner?.displayName?.trim() || + owner?.nip05Handle?.trim() || + formatPubkey(user.ownerPubkey) + ); +} + +type AddMemberSearchCandidate = UserSearchResult & { + isManagedAgent?: boolean; + isMember?: boolean; + personaId?: string | null; +}; + +function addMemberCandidatePersonaId( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +) { + return managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)) + ?.personaId; +} + +function addMemberCandidateIsManagedAgent( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +) { + return managedAgentsByPubkey.has(normalizePubkey(candidate.pubkey)); +} + +function addMemberCandidateWithAgentMetadata( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +): AddMemberSearchCandidate { + return { + ...candidate, + isManagedAgent: addMemberCandidateIsManagedAgent( + candidate, + managedAgentsByPubkey, + ), + personaId: addMemberCandidatePersonaId(candidate, managedAgentsByPubkey), + }; +} + function memberModalRoleRank(member: ChannelMember) { if (member.role === "owner") return 0; if (member.role === "admin") return 1; @@ -108,8 +169,10 @@ export function MembersSidebar({ const [addingMemberPubkeys, setAddingMemberPubkeys] = React.useState< ReadonlySet >(() => new Set()); + const identityQuery = useIdentityQuery(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); + const channelsQuery = useChannelsQuery({ enabled: open }); const changeRoleMutation = useMutation({ mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { if (!channelId) throw new Error("No channel selected."); @@ -208,19 +271,36 @@ export function MembersSidebar({ return []; } - const candidatesByPubkey = new Map(); - const eligibleAgentPubkeys = new Set([ - ...(managedAgentsQuery.data ?? []).map((agent) => + const candidatesByPubkey = new Map(); + const managedAgentsByPubkey = new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ normalizePubkey(agent.pubkey), + agent, + ]), + ); + const memberAgentLabels = new Set( + rawMembers + .filter((member) => member.isAgent === true || member.role === "bot") + .map((member) => member.displayName?.trim().toLowerCase()) + .filter((label): label is string => Boolean(label)), + ); + const sharedChannelIds = getSharedChannelIds(channelsQuery.data); + const eligibleAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( + (agent) => agent.pubkey, ), - ...(relayAgentsQuery.data ?? []) - .filter((agent) => agent.respondTo === "anyone") - .map((agent) => normalizePubkey(agent.pubkey)), - ]); + relayAgents: relayAgentsQuery.data, + sharedChannelIds, + }); - const addCandidate = (candidate: UserSearchResult) => { + const addCandidate = (candidate: AddMemberSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); if ( + (candidate.isAgent && + memberAgentLabels.has( + formatAddCandidateName(candidate).toLowerCase(), + )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || (candidate.isAgent && !eligibleAgentPubkeys.has(pubkey)) @@ -249,15 +329,20 @@ export function MembersSidebar({ nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, isAgent: current.isAgent || candidate.isAgent, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + isMember: current.isMember || candidate.isMember, + personaId: current.personaId ?? candidate.personaId, }); }; for (const user of userSearchQuery.data ?? []) { - addCandidate(user); + addCandidate( + addMemberCandidateWithAgentMetadata(user, managedAgentsByPubkey), + ); } for (const agent of relayAgentsQuery.data ?? []) { - if (agent.respondTo !== "anyone") { + if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) { continue; } @@ -277,13 +362,24 @@ export function MembersSidebar({ displayName: agent.name, avatarUrl: null, nip05Handle: null, - ownerPubkey: null, + ownerPubkey: currentPubkey ?? null, isAgent: true, + isManagedAgent: true, + personaId: agent.personaId, }); } + const coalescedCandidates = coalesceAgentAutocompleteCandidates( + [...candidatesByPubkey.values()], + { + currentPubkey, + getLabel: formatAddCandidateName, + preferredPubkeys: memberPubkeys, + }, + ); + return rankUserCandidatesBySearch({ - candidates: [...candidatesByPubkey.values()], + candidates: coalescedCandidates, getLabel: formatAddCandidateName, limit: MEMBER_ADD_RESULT_LIMIT, query: normalizedDeferredSearchQuery, @@ -291,16 +387,43 @@ export function MembersSidebar({ }, [ canAddMembers, isArchivedDiscovery, + channelsQuery.data, + currentPubkey, managedAgentsQuery.data, memberPubkeys, normalizedDeferredSearchQuery, relayAgentsQuery.data, userSearchQuery.data, + rawMembers, ]); const isAddSearchLoading = userSearchQuery.isLoading || managedAgentsQuery.isLoading || - relayAgentsQuery.isLoading; + relayAgentsQuery.isLoading || + channelsQuery.isLoading; + const addSearchOwnerPubkeys = React.useMemo( + () => [ + ...new Set( + addSearchResults + .map((user) => user.ownerPubkey) + .filter((pubkey): pubkey is string => + Boolean( + pubkey && + pubkey.toLowerCase() !== + identityQuery.data?.pubkey?.toLowerCase(), + ), + ), + ), + ], + [addSearchResults, identityQuery.data?.pubkey], + ); + const addSearchOwnerProfilesQuery = useUsersBatchQuery( + addSearchOwnerPubkeys, + { + enabled: open && addSearchOwnerPubkeys.length > 0, + }, + ); + const filteredArchivedMembers = React.useMemo(() => { if (!normalizedSearchQuery) { return archived; @@ -584,6 +707,10 @@ export function MembersSidebar({ onSelect={(selectedUser) => { void handleAddSearchResult(selectedUser); }} + ownerLabel={formatOwnerName( + user, + addSearchOwnerProfilesQuery.data?.profiles, + )} user={user} /> ))} @@ -714,10 +841,12 @@ function SearchResultSectionTitle({ function AddMemberSearchResultRow({ disabled, onSelect, + ownerLabel, user, }: { disabled: boolean; onSelect: (user: UserSearchResult) => void; + ownerLabel?: string | null; user: UserSearchResult; }) { return ( @@ -743,14 +872,21 @@ function AddMemberSearchResultRow({ />
{user.isAgent ? ( -
- - {formatAddCandidateName(user)} - - - +
+
+ + {formatAddCandidateName(user)} + + + +
+ {ownerLabel ? ( + + owned by {ownerLabel} + + ) : null}
) : ( diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index ab0f9c3704..5b6e787a45 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -5,10 +5,23 @@ import { usePersonasQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + useChannelMembersQuery, + useChannelsQuery, +} from "@/features/channels/hooks"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; -import { useUserSearchQuery } from "@/features/profile/hooks"; +import { + coalesceAgentAutocompleteCandidates, + coalesceAutocompleteCandidatesByKey, + getMentionableAgentPubkeys, + getSharedChannelIds, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { AutocompleteEdit } from "./useRichTextEditor"; import type { AgentPersona, @@ -36,9 +49,34 @@ type MentionCandidate = { role?: ChannelRole | null; personaName?: string | null; secondaryLabel?: string | null; + ownerPubkey?: string | null; isAgent: boolean; + isManagedAgent?: boolean; + isGlobalSearchResult?: boolean; }; +function mentionCandidateLabel(candidate: MentionCandidate) { + return candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona"; +} + +function globalSearchIdentityKey(candidate: MentionCandidate) { + if ( + !candidate.isGlobalSearchResult || + candidate.isMember || + candidate.isAgent + ) { + return null; + } + + const label = candidate.displayName?.trim().toLowerCase(); + if (!label) { + return null; + } + + const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? ""; + return `global-person:${label}:${secondaryLabel}`; +} + export type PersonaMentionTarget = { displayName: string; persona: AgentPersona; @@ -63,6 +101,22 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) { return null; } +function formatOwnerLabel( + ownerPubkey: string | null | undefined, + ownerProfiles?: UserProfileLookup, +) { + if (!ownerPubkey) { + return null; + } + + const owner = ownerProfiles?.[normalizePubkey(ownerPubkey)]; + return ( + owner?.displayName?.trim() || + owner?.nip05Handle?.trim() || + `${ownerPubkey.slice(0, 8)}…` + ); +} + export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -85,11 +139,19 @@ export function useMentions( options?.channelType === "dm" || options?.channelType === "stream" || options?.channelType === "forum"; + const mentionSearchQuery = mentionQuery?.trim() ?? ""; + const canSearchGlobalPeople = + canSearchAllUsers && mentionSearchQuery.length > 0; + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : null; const membersQuery = useChannelMembersQuery(channelId); const members = externalMembers ?? membersQuery.data; const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); + const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const managedAgentDirectoryReady = managedAgentsQuery.data !== undefined || @@ -100,7 +162,9 @@ export function useMentions( !relayAgentsQuery.isLoading || relayAgentsQuery.error !== null; const canSearchGlobalUsers = - canSearchAllUsers && managedAgentDirectoryReady && relayAgentDirectoryReady; + canSearchGlobalPeople && + managedAgentDirectoryReady && + relayAgentDirectoryReady; const userSearchQuery = useUserSearchQuery(mentionQuery ?? "", { allowEmpty: true, enabled: canSearchGlobalUsers && mentionQuery !== null, @@ -156,24 +220,25 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const publicRelayAgentPubkeys = React.useMemo( + const sharedChannelIds = React.useMemo( + () => getSharedChannelIds(channelsQuery.data), + [channelsQuery.data], + ); + const mentionableAgentPubkeys = React.useMemo( () => - new Set( - (relayAgentsQuery.data ?? []) - .filter((agent) => agent.respondTo === "anyone") - .map((agent) => normalizePubkey(agent.pubkey)), - ), - [relayAgentsQuery.data], + getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys, + relayAgents: relayAgentsQuery.data, + sharedChannelIds, + }), + [ + currentPubkey, + managedAgentPubkeys, + relayAgentsQuery.data, + sharedChannelIds, + ], ); - const mentionableAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(managedAgentPubkeys); - // Non-managed agents only appear in mention autocomplete when they - // advertise that any channel member can wake them with a mention. - for (const pubkey of publicRelayAgentPubkeys) { - pubkeys.add(pubkey); - } - return pubkeys; - }, [managedAgentPubkeys, publicRelayAgentPubkeys]); const personaNameByPubkey = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personas = personasQuery.data ?? []; @@ -200,6 +265,11 @@ export function useMentions( () => new Set(activePersonas.map((persona) => persona.id)), [activePersonas], ); + const memberPubkeys = React.useMemo( + () => + new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), + [members], + ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); @@ -239,6 +309,8 @@ export function useMentions( role: current.role ?? candidate.role ?? null, secondaryLabel: current.secondaryLabel ?? candidate.secondaryLabel ?? null, + ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, }); }; @@ -283,6 +355,7 @@ export function useMentions( pubkey: agent.pubkey, displayName: agent.name, isMember: false, + ownerPubkey: null, isAgent: true, }); } @@ -294,9 +367,11 @@ export function useMentions( displayName: agent.name, isMember: false, isAgent: true, + isManagedAgent: true, personaId: agent.personaId ?? undefined, personaName: personaNameByPubkey.get(normalizePubkey(agent.pubkey)) ?? null, + ownerPubkey: currentPubkey, }); } } @@ -319,6 +394,11 @@ export function useMentions( personaName: personaNameByPubkey.get(normalizePubkey(user.pubkey)) ?? null, secondaryLabel: formatSearchUserSecondaryLabel(user), + ownerPubkey: user.ownerPubkey ?? null, + isGlobalSearchResult: true, + isManagedAgent: managedAgentNamesByPubkey.has( + normalizePubkey(user.pubkey), + ), }); } } @@ -335,16 +415,28 @@ export function useMentions( })) .filter((candidate) => candidate.displayName.trim().length > 0); - return [...candidatesByPubkey.values(), ...personaCandidates]; + return coalesceAgentAutocompleteCandidates( + coalesceAutocompleteCandidatesByKey( + [...candidatesByPubkey.values(), ...personaCandidates], + globalSearchIdentityKey, + ), + { + currentPubkey, + getLabel: mentionCandidateLabel, + preferredPubkeys: memberPubkeys, + }, + ); }, [ activePersonas, canSearchAllUsers, canSearchGlobalUsers, + currentPubkey, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, managedAgentsQuery.data, + memberPubkeys, members, mentionableAgentPubkeys, personaNameByPubkey, @@ -354,11 +446,19 @@ export function useMentions( userSearchQuery.data, ]); - const memberPubkeys = React.useMemo( - () => - new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), - [members], + const ownerPubkeys = React.useMemo( + () => [ + ...new Set( + mentionCandidates + .map((candidate) => candidate.ownerPubkey) + .filter((pubkey): pubkey is string => Boolean(pubkey)), + ), + ], + [mentionCandidates], ); + const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { + enabled: ownerPubkeys.length > 0, + }); const searchableNames = React.useMemo(() => { const names: string[] = []; @@ -494,7 +594,14 @@ export function useMentions( : null; const score = labelScore !== null ? labelScore : pubkeyScore; - return { candidate, groupRank, label, order, score }; + const ownerLabel = candidate.isAgent + ? formatOwnerLabel( + candidate.ownerPubkey, + ownerProfilesQuery.data?.profiles, + ) + : null; + + return { candidate, groupRank, label, order, ownerLabel, score }; }) .filter( (item): item is typeof item & { score: number } => item.score !== null, @@ -504,7 +611,7 @@ export function useMentions( a.groupRank - b.groupRank || a.score - b.score || a.order - b.order, ) .slice(0, MENTION_SUGGESTION_LIMIT) - .map(({ candidate, label }) => ({ + .map(({ candidate, label, ownerLabel }) => ({ pubkey: candidate.pubkey, personaId: candidate.personaId, kind: candidate.kind, @@ -518,6 +625,7 @@ export function useMentions( isAgent: candidate.isAgent, notInChannel: options?.channelType !== "dm" && candidate.isMember === false, + ownerLabel, role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, })); }, [ @@ -525,6 +633,7 @@ export function useMentions( mentionCandidates, mentionQuery, options?.channelType, + ownerProfilesQuery.data?.profiles, profiles, ]); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index c548e3726a..9eec93220f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -18,6 +18,7 @@ export type MentionSuggestion = { avatarUrl?: string | null; isAgent?: boolean; notInChannel?: boolean; + ownerLabel?: string | null; role?: string | null; }; @@ -121,16 +122,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ {suggestion.role} ) : null} - {suggestion.notInChannel ? ( + {suggestion.ownerLabel || suggestion.notInChannel ? ( - not in channel + {suggestion.ownerLabel + ? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}` + : "not in channel"} ) : null} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 84351e3769..947eb2ff52 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -111,6 +111,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { ? "online" : "offline", respondTo: agent.respondTo, + respondToAllowlist: agent.respondToAllowlist, }); } } diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx index 64b65fa3f3..8a9b63eda1 100644 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx @@ -5,15 +5,29 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { + coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, +} from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { useUserSearchQuery } from "@/features/profile/hooks"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; import { truncatePubkey } from "@/features/profile/lib/identity"; import { getKeyboardSearchSelection, rankUserCandidatesBySearch, } from "@/features/profile/lib/userCandidateSearch"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { UserSearchResult } from "@/shared/api/types"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { + ManagedAgent, + UserSearchResult, + UserProfileSummary, +} from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -49,6 +63,44 @@ function formatUserName(user: UserSearchResult) { ); } +function formatOwnerName( + user: UserSearchResult, + ownerProfiles?: Record, +) { + if (!user.ownerPubkey) { + return null; + } + + const owner = ownerProfiles?.[normalizePubkey(user.ownerPubkey)]; + return ( + owner?.displayName?.trim() || + owner?.nip05Handle?.trim() || + truncatePubkey(user.ownerPubkey) + ); +} + +type DirectMessageSearchCandidate = UserSearchResult & { + isManagedAgent?: boolean; + isMember?: boolean; + personaId?: string | null; +}; + +function directMessageCandidateWithAgentMetadata( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +): DirectMessageSearchCandidate { + const agent = managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)); + return { + ...candidate, + isManagedAgent: Boolean(agent), + personaId: agent?.personaId, + }; +} + +function formatDirectMessageSearchName(user: DirectMessageSearchCandidate) { + return formatUserName(user); +} + function prefersReducedMotion() { return ( typeof window !== "undefined" && @@ -263,8 +315,10 @@ export function NewDirectMessageDialog({ () => new Set(selectedUsers.map((user) => normalizePubkey(user.pubkey))), [selectedUsers], ); + const identityQuery = useIdentityQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: open }); const relayAgentsQuery = useRelayAgentsQuery({ enabled: open }); + const channelsQuery = useChannelsQuery({ enabled: open }); const userSearchQuery = useUserSearchQuery(deferredSearchQuery, { allowEmpty: true, enabled: open && !hasReachedRecipientLimit, @@ -272,20 +326,26 @@ export function NewDirectMessageDialog({ }); const isArchivedDiscovery = useIsArchivedPredicate(); const searchResults = React.useMemo(() => { - const candidatesByPubkey = new Map(); + const candidatesByPubkey = new Map(); + const managedAgentsByPubkey = new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent, + ]), + ); const currentPubkeyNormalized = currentPubkey ? normalizePubkey(currentPubkey) : null; - const eligibleAgentPubkeys = new Set([ - ...(managedAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), + const eligibleAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( + (agent) => agent.pubkey, ), - ...(relayAgentsQuery.data ?? []) - .filter((agent) => agent.respondTo === "anyone") - .map((agent) => normalizePubkey(agent.pubkey)), - ]); + relayAgents: relayAgentsQuery.data, + sharedChannelIds: getSharedChannelIds(channelsQuery.data), + }); - const addCandidate = (candidate: UserSearchResult) => { + const addCandidate = (candidate: DirectMessageSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); if ( @@ -318,15 +378,20 @@ export function NewDirectMessageDialog({ nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, isAgent: current.isAgent || candidate.isAgent, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + isMember: current.isMember || candidate.isMember, + personaId: current.personaId ?? candidate.personaId, }); }; for (const user of userSearchQuery.data ?? []) { - addCandidate(user); + addCandidate( + directMessageCandidateWithAgentMetadata(user, managedAgentsByPubkey), + ); } for (const agent of relayAgentsQuery.data ?? []) { - if (agent.respondTo !== "anyone") { + if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) { continue; } @@ -346,19 +411,30 @@ export function NewDirectMessageDialog({ displayName: agent.name, avatarUrl: null, nip05Handle: null, - ownerPubkey: null, + ownerPubkey: currentPubkey ?? null, isAgent: true, + isManagedAgent: true, + personaId: agent.personaId, }); } + const coalescedCandidates = coalesceAgentAutocompleteCandidates( + [...candidatesByPubkey.values()], + { + currentPubkey, + getLabel: formatDirectMessageSearchName, + }, + ); + return rankUserCandidatesBySearch({ allowEmptyQuery: true, - candidates: [...candidatesByPubkey.values()], + candidates: coalescedCandidates, getLabel: formatUserName, limit: DIRECT_MESSAGE_RECIPIENT_LIMIT, query: deferredSearchQuery, }); }, [ + channelsQuery.data, currentPubkey, deferredSearchQuery, isArchivedDiscovery, @@ -370,7 +446,28 @@ export function NewDirectMessageDialog({ const isDirectoryLoading = userSearchQuery.isLoading || managedAgentsQuery.isLoading || - relayAgentsQuery.isLoading; + relayAgentsQuery.isLoading || + channelsQuery.isLoading; + + const searchOwnerPubkeys = React.useMemo( + () => [ + ...new Set( + searchResults + .map((user) => user.ownerPubkey) + .filter((pubkey): pubkey is string => + Boolean( + pubkey && + pubkey.toLowerCase() !== + identityQuery.data?.pubkey?.toLowerCase(), + ), + ), + ), + ], + [identityQuery.data?.pubkey, searchResults], + ); + const ownerProfilesQuery = useUsersBatchQuery(searchOwnerPubkeys, { + enabled: open && searchOwnerPubkeys.length > 0, + }); React.useEffect(() => { if (!open) { @@ -605,79 +702,91 @@ export function NewDirectMessageDialog({
{searchResults.length > 0 ? (
- {searchResults.map((user) => ( -
-
+
- -
- ))} + ); + })}
) : isDirectoryLoading ? (

diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 5b63b6cdd7..c8267f9721 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1,5 +1,4 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; - import type { AddChannelMembersInput, AddChannelMembersResult, @@ -189,8 +188,8 @@ type RawRelayAgent = { capabilities: string[]; status: RelayAgent["status"]; respond_to?: RelayAgent["respondTo"]; + respond_to_allowlist?: string[]; }; - export type RawManagedAgent = { pubkey: string; name: string; @@ -846,6 +845,7 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { capabilities: agent.capabilities, status: agent.status, respondTo: agent.respond_to ?? null, + respondToAllowlist: agent.respond_to_allowlist ?? [], }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ec4ed526a7..4f282342e4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -265,6 +265,7 @@ export type RelayAgent = { capabilities: string[]; status: "online" | "away" | "offline"; respondTo: RespondToMode | null; + respondToAllowlist: string[]; }; export type ManagedAgentBackend = diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 04f8bfe987..a4f457d749 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -44,6 +44,18 @@ type MockManagedAgentSeed = { channelNames?: string[]; channelIds?: string[]; backend?: RawManagedAgent["backend"]; + respondTo?: RawManagedAgent["respond_to"]; + respondToAllowlist?: string[]; +}; + +type MockRelayAgentSeed = { + pubkey: string; + name: string; + respondTo?: RawRelayAgent["respond_to"]; + respondToAllowlist?: string[]; + channelNames?: string[]; + channelIds?: string[]; + status?: PresenceStatus; }; type MockSearchProfileSeed = { @@ -67,6 +79,7 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + relayAgents?: MockRelayAgentSeed[]; agentMemory?: RawAgentMemoryListing | Record; createManagedAgentDelayMs?: number; channelsReadError?: string; @@ -355,6 +368,7 @@ type RawRelayAgent = { capabilities: string[]; status: PresenceStatus; respond_to?: "owner-only" | "allowlist" | "anyone"; + respond_to_allowlist?: string[]; }; type RawManagedAgent = { @@ -1010,8 +1024,8 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { start_on_app_launch: true, backend: seed.backend ?? { type: "local" }, backend_agent_id: null, - respond_to: "owner-only", - respond_to_allowlist: [], + respond_to: seed.respondTo ?? "owner-only", + respond_to_allowlist: seed.respondToAllowlist ?? [], private_key_nsec: `nsec1mock${seed.pubkey.slice(0, 20)}`, log_lines: [ `buzz-acp starting: relay=${DEFAULT_RELAY_WS_URL} agent_pubkey=${seed.pubkey} parallelism=1`, @@ -1020,6 +1034,36 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { }; } +function resetMockRelayAgents(config?: E2eConfig) { + mockRelayAgents = defaultMockRelayAgents.map((agent) => ({ + ...agent, + channels: [...agent.channels], + channel_ids: [...agent.channel_ids], + capabilities: [...agent.capabilities], + respond_to_allowlist: [...(agent.respond_to_allowlist ?? [])], + })); + + for (const seed of config?.mock?.relayAgents ?? []) { + const channels = mockChannels.filter((channel) => { + return ( + seed.channelIds?.includes(channel.id) || + seed.channelNames?.includes(channel.name) + ); + }); + mockRelayAgents.push({ + pubkey: seed.pubkey, + name: seed.name, + agent_type: "goose", + channels: channels.map((channel) => channel.name), + channel_ids: channels.map((channel) => channel.id), + capabilities: ["messages", "channels", "mcp"], + status: seed.status ?? "online", + respond_to: seed.respondTo ?? "owner-only", + respond_to_allowlist: seed.respondToAllowlist ?? [], + }); + } +} + function resetMockManagedAgents(config?: E2eConfig) { mockManagedAgents = []; @@ -1563,7 +1607,7 @@ function resetMockMesh() { } let mockPersonas: RawPersona[] = []; let mockTeams: RawTeam[] = []; -let mockRelayAgents: RawRelayAgent[] = [ +const defaultMockRelayAgents: RawRelayAgent[] = [ { pubkey: ALICE_PUBKEY, name: "alice", @@ -1576,6 +1620,7 @@ let mockRelayAgents: RawRelayAgent[] = [ capabilities: ["search", "summaries", "workflows"], status: "online", respond_to: "anyone", + respond_to_allowlist: [], }, { pubkey: CHARLIE_PUBKEY, @@ -1586,8 +1631,16 @@ let mockRelayAgents: RawRelayAgent[] = [ capabilities: ["code", "reviews"], status: "away", respond_to: "anyone", + respond_to_allowlist: [], }, ]; +let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({ + ...agent, + channels: [...agent.channels], + channel_ids: [...agent.channel_ids], + capabilities: [...agent.capabilities], + respond_to_allowlist: [...(agent.respond_to_allowlist ?? [])], +})); // ── Workflow mocks ───────────────────────────────────────────────────────── @@ -1874,6 +1927,7 @@ function syncMockRelayAgentsFromManagedAgents() { ? "online" : "offline", respond_to: agent.respond_to, + respond_to_allowlist: [...agent.respond_to_allowlist], }; }, ); @@ -6131,6 +6185,7 @@ export function maybeInstallE2eTauriMocks() { } resetMockRelayMembers(config); + resetMockRelayAgents(config); resetMockManagedAgents(config); resetMockPersonas(config); resetMockTeams(); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 2509b66841..db57609306 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1391,6 +1391,60 @@ test("channel header omits the add agent action", async ({ page }) => { await expect(page.getByTestId("channel-management-trigger")).toBeVisible(); }); +test("members sidebar collapses same-persona managed agents", async ({ + page, +}) => { + const inChannelAgentPubkey = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + const outOfChannelAgentPubkey = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + await installMockBridge(page, { + managedAgents: [ + { + pubkey: outOfChannelAgentPubkey, + name: "Pinky", + personaId: "builtin:fizz", + status: "stopped", + }, + { + pubkey: inChannelAgentPubkey, + name: "Pinky", + personaId: "builtin:fizz", + status: "running", + channelNames: ["general"], + }, + ], + searchProfiles: [ + { + pubkey: outOfChannelAgentPubkey, + displayName: "Pinky", + ownerPubkey: MOCK_IDENTITY_PUBKEY, + isAgent: true, + }, + { + pubkey: inChannelAgentPubkey, + displayName: "Pinky", + ownerPubkey: MOCK_IDENTITY_PUBKEY, + isAgent: true, + }, + ], + userSearchDelayMs: 1_000, + }); + await page.goto("/"); + await openMembersSidebar(page, "general"); + + await page.getByTestId("channel-management-search-users").fill("pi"); + + await expect( + page.getByTestId(`channel-user-search-result-${inChannelAgentPubkey}`), + ).toHaveCount(0); + await expect( + page.getByTestId(`channel-user-search-result-${outOfChannelAgentPubkey}`), + ).toHaveCount(0); + await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1); +}); + test("private-channel members can add members and bots without admin", async ({ page, }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 8c851931a7..c969bd2a03 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -18,10 +18,12 @@ const OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; const REUSABLE_PERSONA_AGENT_PUBKEY = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; +const ALLOWLIST_RELAY_AGENT_PUBKEY = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; const CASEY_PROFILE_PUBKEY = "1111111111111111111111111111111111111111111111111111111111111111"; -const CASEY_PILOT_PROFILE_PUBKEY = - "2222222222222222222222222222222222222222222222222222222222222222"; +const PROFILE_ONLY_AGENT_PUBKEY = + "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; const SYSTEM_MESSAGE_KIND = 40099; /** Locator scoped to the mention autocomplete dropdown inside the composer. */ @@ -40,21 +42,6 @@ async function readCommandLog(page: import("@playwright/test").Page) { }); } -async function readCommandPayloads(page: import("@playwright/test").Page) { - return page.evaluate(() => { - return ( - ( - window as Window & { - __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ - command: string; - payload: unknown; - }>; - } - ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [] - ); - }); -} - function commandCount(commands: string[], command: string) { return commands.filter((entry) => entry === command).length; } @@ -116,12 +103,10 @@ test("@ trigger shows unified autocomplete with agents first", async ({ await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); - await expect(dropdown.getByText("outsider")).toBeVisible(); + await expect(dropdown.getByText("outsider")).toHaveCount(0); const charlieRow = dropdown.locator("button", { hasText: "charlie" }); - const outsiderRow = dropdown.locator("button", { hasText: "outsider" }); await expect(charlieRow.getByTestId("mention-agent-icon")).toBeVisible(); await expect(charlieRow.getByText("not in channel")).toBeVisible(); - await expect(outsiderRow.getByText("not in channel")).toBeVisible(); await expect( dropdown .locator("button", { hasText: "alice" }) @@ -143,13 +128,11 @@ test("@ trigger shows unified autocomplete with agents first", async ({ expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); - expect(outsiderIndex).toBeGreaterThanOrEqual(0); + expect(outsiderIndex).toEqual(-1); expect(fizzIndex).toBeLessThan(aliceIndex); expect(fizzIndex).toBeLessThan(bobIndex); expect(aliceIndex).toBeLessThan(charlieIndex); expect(bobIndex).toBeLessThan(charlieIndex); - expect(aliceIndex).toBeLessThan(outsiderIndex); - expect(bobIndex).toBeLessThan(outsiderIndex); }); test("autocomplete filters suggestions as user types", async ({ page }) => { @@ -165,26 +148,28 @@ test("autocomplete filters suggestions as user types", async ({ page }) => { await expect(dropdown.getByText("bob")).not.toBeVisible(); }); -test("autocomplete stays open while expanded search results load", async ({ +test("autocomplete searches global non-member people from the first typed character", async ({ page, }) => { - await installMockBridge(page, { userSearchDelayMs: 1_000 }); + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: CASEY_PROFILE_PUBKEY, + displayName: "tessa", + }, + ], + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const input = page.getByTestId("message-input"); - await input.fill("@"); + await input.fill("@t"); const dropdown = autocomplete(page); - await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toBeVisible(); - - await input.fill("@zzzz"); - await page.waitForTimeout(250); - - await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toBeVisible(); + const tessaRow = dropdown.locator("button", { hasText: "tessa" }); + await expect(tessaRow).toBeVisible(); + await expect(tessaRow.getByText("not in channel")).toBeVisible(); }); test("selecting a person mention inserts @Name into input", async ({ @@ -406,10 +391,20 @@ test("relay-profile agents with member roles use the agent composer style", asyn await expect(agentMentionChip).toHaveText("charlie"); }); -test("profile-only agents without public respond-to are hidden from mentions", async ({ +test("other-owned agents without a shared channel are hidden from mentions", async ({ page, }) => { - await installMockBridge(page, { userSearchDelayMs: 1_000 }); + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: PROFILE_ONLY_AGENT_PUBKEY, + displayName: "mira", + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + isAgent: true, + }, + ], + userSearchDelayMs: 1_000, + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -422,6 +417,68 @@ test("profile-only agents without public respond-to are hidden from mentions", a await expect(input.locator(".mention-chip")).toHaveCount(0); }); +test("own profile-only agents are hidden from channel mentions", async ({ + page, +}) => { + await installMockBridge(page, { userSearchDelayMs: 1_000 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@mira"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + +test("allowlisted relay agents are visible in channel mentions", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: ["deadbeef".repeat(8)], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("quinn")).toBeVisible(); + await expect(dropdown.getByText("agent")).toBeVisible(); +}); + +test("non-allowlisted relay agents stay hidden from channel mentions", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + test("mentioning an in-channel stopped managed agent starts it before sending", async ({ page, }) => { @@ -766,7 +823,7 @@ test("selecting a non-member agent from a DM inserts @Name into input", async ({ await expect(input.locator(".mention-chip")).toBeVisible(); }); -test("do nothing sends a non-member mention without inviting", async ({ +test("global non-member people can be selected from channel mentions", async ({ page, }) => { await page.goto("/"); @@ -775,101 +832,25 @@ test("do nothing sends a non-member mention without inviting", async ({ const input = page.getByTestId("message-input"); await input.fill("Loop in @out"); - const initialMessageCount = await page.getByTestId("message-row").count(); - const initialAddMemberCount = commandCount( - await readCommandLog(page), - "add_channel_members", - ); const dropdown = autocomplete(page); await expect(dropdown.getByText("outsider")).toBeVisible(); - await input.press("Enter"); - await page.getByTestId("send-message").click(); - - const dialog = page.getByRole("alertdialog"); - await expect(dialog).toBeVisible(); - await expect(dialog).toContainText("outsider"); - await expect( - dialog.getByRole("button", { name: "Do nothing" }), - ).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Invite" })).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Cancel" })).toHaveCount(0); - await expect( - dialog.getByRole("button", { name: "Reference only" }), - ).toHaveCount(0); - await expect(dialog.getByRole("button", { name: "Notify" })).toHaveCount(0); - - await dialog.getByRole("button", { name: "Do nothing" }).click(); - await expect(page.getByRole("alertdialog")).toHaveCount(0); - await expect(page.getByTestId("message-row")).toHaveCount( - initialMessageCount + 1, - ); - await expect(input).toHaveText(""); - - const mentionChip = page - .getByTestId("message-row") - .last() - .locator("[data-mention]", { hasText: "@outsider" }); - await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator("svg")).toHaveCount(0); - expect( - commandCount(await readCommandLog(page), "add_channel_members"), - ).toEqual(initialAddMemberCount); - - await mentionChip.click(); - await expect(page.getByTestId("user-profile-panel")).toBeVisible(); - await expect(page.getByTestId("user-profile-panel")).toContainText( - "outsider", - ); + await expect(dropdown.getByText("not in channel")).toBeVisible(); }); -test("invite action adds non-member before sending mention", async ({ - page, -}) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - const input = page.getByTestId("message-input"); - await input.fill("Loop in @out"); - - const dropdown = autocomplete(page); - await expect(dropdown.getByText("outsider")).toBeVisible(); - await input.press("Enter"); - await page.getByTestId("send-message").click(); - - const dialog = page.getByRole("alertdialog"); - await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: "Invite" }).click(); - - await expect - .poll(async () => { - const commands = await readCommandLog(page); - return commands.filter((command) => command === "add_channel_members") - .length; - }) - .toBeGreaterThan(0); - - const mentionChip = page - .getByTestId("message-row") - .last() - .locator("[data-mention]", { hasText: "@outsider" }); - await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator("svg")).toHaveCount(0); -}); - -test("invite action only adds the selected non-member profile", async ({ +test("duplicate global people with the same visible identity collapse in channel mentions", async ({ page, }) => { await installMockBridge(page, { searchProfiles: [ { pubkey: CASEY_PROFILE_PUBKEY, - displayName: "casey", + displayName: "Pip", }, { - pubkey: CASEY_PILOT_PROFILE_PUBKEY, - displayName: "casey pilot", + pubkey: + "2222222222222222222222222222222222222222222222222222222222222222", + displayName: "Pip", }, ], }); @@ -878,31 +859,10 @@ test("invite action only adds the selected non-member profile", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); const input = page.getByTestId("message-input"); - const dropdown = autocomplete(page); - await input.fill("Loop in @"); - await expect(dropdown.getByText("casey pilot")).toBeVisible(); - await input.fill("Loop in @casey p"); - await expect(dropdown.getByText("casey pilot")).toBeVisible(); - await dropdown.getByText("casey pilot").click(); - await page.getByTestId("send-message").click(); - - const dialog = page.getByRole("alertdialog"); - await expect(dialog).toBeVisible(); - await expect(dialog).toContainText("casey pilot"); - await dialog.getByRole("button", { name: "Invite" }).click(); + await input.fill("@pip"); - await expect - .poll(async () => { - return readCommandPayloads(page); - }) - .toContainEqual( - expect.objectContaining({ - command: "add_channel_members", - payload: expect.objectContaining({ - pubkeys: [CASEY_PILOT_PROFILE_PUBKEY], - }), - }), - ); + const dropdown = autocomplete(page); + await expect(dropdown.locator("button", { hasText: "Pip" })).toHaveCount(1); }); test("sent non-member person mention uses the normal mention style", async ({ diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 7ca83e99c2..8d317d7fee 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -52,6 +52,8 @@ type MockManagedAgentSeed = { backend?: | { type: "local" } | { type: "provider"; id: string; config: Record }; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockSearchProfileSeed = { @@ -60,9 +62,20 @@ type MockSearchProfileSeed = { avatarUrl?: string | null; nip05Handle?: string | null; about?: string | null; + ownerPubkey?: string | null; isAgent?: boolean; }; +type MockRelayAgentSeed = { + pubkey: string; + name: string; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; + channelNames?: string[]; + channelIds?: string[]; + status?: "online" | "away" | "offline"; +}; + export type MockEngramEntry = { slug: string; body: string; @@ -91,6 +104,7 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + relayAgents?: MockRelayAgentSeed[]; createManagedAgentDelayMs?: number; channelsReadError?: string; feedReadError?: string;