fix(desktop): scope mention/add autocomplete to reachable identities (#1243)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-24 12:57:55 -07:00
committed by GitHub
co-authored by Pinky Brain
parent 09dbeb86fa
commit a3e8fb6fd5
15 changed files with 1160 additions and 286 deletions
@@ -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<RespondTo>,
#[serde(default)]
pub respond_to_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedAgentRecord {
pub pubkey: String,
+20
View File
@@ -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<crate::managed_agents::RelayAgentInfo> =
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);
@@ -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]);
});
@@ -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<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
sharedChannelIds: ReadonlySet<string>,
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<string>;
relayAgents: readonly RelayAgent[] | undefined;
sharedChannelIds: ReadonlySet<string>;
}) {
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<T extends AgentAutocompleteCandidate>(
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<T extends AgentAutocompleteCandidate>(
candidate: T,
currentPubkey: string | null | undefined,
preferredPubkeys: ReadonlySet<string>,
) {
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<T extends AgentAutocompleteCandidate>(
next: T,
current: T,
currentPubkey: string | null | undefined,
preferredPubkeys: ReadonlySet<string>,
) {
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<T>(
candidates: readonly T[],
getKey: (candidate: T) => string | null,
) {
const output: T[] = [];
const indexesByKey = new Map<string, number>();
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<string>;
},
) {
const output: T[] = [];
const indexesByKey = new Map<string, number>();
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;
}
@@ -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<string, ManagedAgent>,
) {
return managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey))
?.personaId;
}
function addMemberCandidateIsManagedAgent(
candidate: UserSearchResult,
managedAgentsByPubkey: ReadonlyMap<string, ManagedAgent>,
) {
return managedAgentsByPubkey.has(normalizePubkey(candidate.pubkey));
}
function addMemberCandidateWithAgentMetadata(
candidate: UserSearchResult,
managedAgentsByPubkey: ReadonlyMap<string, ManagedAgent>,
): 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<string>
>(() => 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<string, UserSearchResult>();
const eligibleAgentPubkeys = new Set([
...(managedAgentsQuery.data ?? []).map((agent) =>
const candidatesByPubkey = new Map<string, AddMemberSearchCandidate>();
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({
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{user.isAgent ? (
<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 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>
{ownerLabel ? (
<span className="block truncate text-xs text-muted-foreground">
owned by {ownerLabel}
</span>
) : null}
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
+135 -26
View File
@@ -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<MentionCandidate[]>(() => {
const candidatesByPubkey = new Map<string, MentionCandidate>();
@@ -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<string[]>(() => {
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,
]);
@@ -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({
<Badge variant="secondary">{suggestion.role}</Badge>
) : null}
</span>
{suggestion.notInChannel ? (
{suggestion.ownerLabel || suggestion.notInChannel ? (
<span
className={cn(
"ml-auto shrink-0 text-xs",
"ml-auto min-w-0 shrink truncate text-xs",
index === selectedIndex
? "text-accent-foreground/65"
: "text-muted-foreground",
)}
>
not in channel
{suggestion.ownerLabel
? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}`
: "not in channel"}
</span>
) : null}
</span>
@@ -111,6 +111,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
? "online"
: "offline",
respondTo: agent.respondTo,
respondToAllowlist: agent.respondToAllowlist,
});
}
}
@@ -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<string, UserProfileSummary>,
) {
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<string, ManagedAgent>,
): 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<string, UserSearchResult>();
const candidatesByPubkey = new Map<string, DirectMessageSearchCandidate>();
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({
<div className="h-[min(50vh,24rem)] overflow-y-auto rounded-xl border border-border/70 bg-background/70">
{searchResults.length > 0 ? (
<div>
{searchResults.map((user) => (
<div
className={cn(
"group/dm-result relative 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",
DM_RESULT_ROW_INSET_DIVIDER_CLASS,
)}
key={user.pubkey}
>
<button
aria-label={`Add ${formatUserName(user)}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`new-dm-result-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={() => {
handleSelectUser(user);
}}
type="button"
/>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{user.isAgent ? (
<div className="relative min-w-0">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot
aria-hidden="true"
className="h-3 w-3"
data-testid="new-dm-agent-icon"
/>
agent
{searchResults.map((user) => {
const ownerLabel = formatOwnerName(
user,
ownerProfilesQuery.data?.profiles,
);
return (
<div
className={cn(
"group/dm-result relative 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",
DM_RESULT_ROW_INSET_DIVIDER_CLASS,
)}
key={user.pubkey}
>
<button
aria-label={`Add ${formatUserName(user)}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`new-dm-result-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={() => {
handleSelectUser(user);
}}
type="button"
/>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{user.isAgent ? (
<div className="relative min-w-0">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot
aria-hidden="true"
className="h-3 w-3"
data-testid="new-dm-agent-icon"
/>
agent
</span>
</div>
{ownerLabel ? (
<span className="block truncate text-xs text-muted-foreground transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
owned by {ownerLabel}
</span>
) : null}
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
</span>
</div>
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
)}
)}
</div>
<Button
aria-label={`Add ${formatUserName(user)}`}
className={cn(
"relative z-20 shrink-0 transition-opacity duration-150 ease-out",
isPending
? "invisible opacity-0 disabled:opacity-0"
: "opacity-0 group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100",
)}
data-testid={`new-dm-add-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={(event) => {
event.stopPropagation();
handleSelectUser(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
<Button
aria-label={`Add ${formatUserName(user)}`}
className={cn(
"relative z-20 shrink-0 transition-opacity duration-150 ease-out",
isPending
? "invisible opacity-0 disabled:opacity-0"
: "opacity-0 group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100",
)}
data-testid={`new-dm-add-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={(event) => {
event.stopPropagation();
handleSelectUser(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
))}
);
})}
</div>
) : isDirectoryLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
+2 -2
View File
@@ -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 ?? [],
};
}
+1
View File
@@ -265,6 +265,7 @@ export type RelayAgent = {
capabilities: string[];
status: "online" | "away" | "offline";
respondTo: RespondToMode | null;
respondToAllowlist: string[];
};
export type ManagedAgentBackend =
+58 -3
View File
@@ -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<string, RawAgentMemoryListing>;
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();
+54
View File
@@ -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,
}) => {
+103 -143
View File
@@ -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,64 +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 ({
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 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",
);
});
test("invite action adds non-member before sending mention", async ({
test("global non-member people can be selected from channel mentions", async ({
page,
}) => {
await page.goto("/");
@@ -835,41 +835,22 @@ test("invite action adds non-member before sending mention", async ({
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);
await expect(dropdown.getByText("not in channel")).toBeVisible();
});
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");
await input.fill("@pip");
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 expect
.poll(async () => {
return readCommandPayloads(page);
})
.toContainEqual(
expect.objectContaining({
command: "add_channel_members",
payload: expect.objectContaining({
pubkeys: [CASEY_PILOT_PROFILE_PUBKEY],
}),
}),
);
await expect(dropdown.locator("button", { hasText: "Pip" })).toHaveCount(1);
});
test("sent non-member person mention uses the normal mention style", async ({
+14
View File
@@ -52,6 +52,8 @@ type MockManagedAgentSeed = {
backend?:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
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;