mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export type RelayAgent = {
|
||||
capabilities: string[];
|
||||
status: "online" | "away" | "offline";
|
||||
respondTo: RespondToMode | null;
|
||||
respondToAllowlist: string[];
|
||||
};
|
||||
|
||||
export type ManagedAgentBackend =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user