Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
coalesceAgentAutocompleteCandidates,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
} from "./agentAutocompleteEligibility.ts";
Expand Down Expand Up @@ -135,6 +136,32 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents",
assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C]));
});

test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => {
const managedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityInManagedList(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
),
false,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ export function getMentionableAgentPubkeys({
return pubkeys;
}

export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}

export function shouldHideAgentFromMentions({
isAgent,
isMember,
Expand Down
25 changes: 4 additions & 21 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import { Bot, UserRoundPlus, X } from "lucide-react";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
useChannelsQuery,
} from "@/features/channels/hooks";
import {
coalesceAgentAutocompleteCandidates,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
Expand Down Expand Up @@ -146,7 +144,6 @@ export function MembersSidebar({
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.");
Expand Down Expand Up @@ -262,15 +259,7 @@ export function MembersSidebar({
.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,
),
relayAgents: relayAgentsQuery.data,
sharedChannelIds,
});
const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys());

const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
Expand All @@ -281,7 +270,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
(candidate.isAgent && !eligibleAgentPubkeys.has(pubkey))
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
) {
return;
}
Expand Down Expand Up @@ -320,10 +309,6 @@ export function MembersSidebar({
}

for (const agent of relayAgentsQuery.data ?? []) {
if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) {
continue;
}

addCandidate({
pubkey: agent.pubkey,
displayName: agent.name,
Expand Down Expand Up @@ -365,7 +350,6 @@ export function MembersSidebar({
}, [
canAddMembers,
isArchivedDiscovery,
channelsQuery.data,
currentPubkey,
managedAgentsQuery.data,
memberPubkeys,
Expand All @@ -377,8 +361,7 @@ export function MembersSidebar({
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading;
relayAgentsQuery.isLoading;
const handlePeopleSearchScroll = useUserSearchFetchMoreOnScroll(
userSearchQuery,
canAddMembers && normalizedDeferredSearchQuery.length > 0,
Expand Down
12 changes: 6 additions & 6 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
coalesceAutocompleteCandidatesByKey,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
shouldHideAgentFromMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import {
Expand Down Expand Up @@ -245,6 +246,9 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
return;
}
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
Expand All @@ -256,7 +260,6 @@ export function useMentions(
) {
return;
}

const current = candidatesByPubkey.get(pubkey);
if (!current) {
candidatesByPubkey.set(pubkey, { ...candidate, pubkey });
Expand Down Expand Up @@ -289,7 +292,6 @@ export function useMentions(
isManagedAgent: current.isManagedAgent || candidate.isManagedAgent,
});
};

for (const member of members ?? []) {
const pubkey = normalizePubkey(member.pubkey);
const linkedPersonaId = activePersonaById.has(pubkey)
Expand Down Expand Up @@ -418,6 +420,7 @@ export function useMentions(
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
managedAgentPubkeys,
managedAgentsQuery.data,
memberPubkeys,
members,
Expand Down Expand Up @@ -540,10 +543,7 @@ export function useMentions(
mentionQuery,
activePersonaIds,
)
.slice(
0,
Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidatesWithTeams.length),
)
.slice(0, MENTION_SUGGESTION_LIMIT)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve paged mention results after scrolling

With a broad mention query that matches more than 50 identities, MentionAutocomplete still calls fetchMoreSuggestions on scroll, but this fixed slice discards everything after the first 50 ranked items on every render. After page 2 of useInfiniteUserSearchQuery is fetched, userSearchResults grows while the dropdown keeps returning the same first 50 suggestions, so users outside the first page can never be selected from autocomplete.

Useful? React with 👍 / 👎.

.map(({ candidate, label }) =>
mapMentionCandidateToSuggestion({
candidate,
Expand Down
39 changes: 32 additions & 7 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,10 +812,17 @@ test("routes an agent mention from an existing DM to the expanded conversation",
);
});

test("routes a relay-agent mention from an existing DM to the expanded conversation", async ({
test("routes a managed relay-agent mention from an existing DM to the expanded conversation", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
name: "quinn",
status: "stopped",
},
],
relayAgents: [
{
pubkey: DM_RELAY_AGENT_PUBKEY,
Expand Down Expand Up @@ -856,15 +863,13 @@ test("routes a relay-agent mention from an existing DM to the expanded conversat
page.locator("[data-active='true'][data-channel-id]"),
).toHaveAttribute("data-channel-id", sentChannelId ?? "");
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText(
DM_RELAY_AGENT_PUBKEY.slice(0, 8),
);
await expect(page.getByTestId("chat-title")).toContainText("quinn");

const sendCommands = (await readCommandPayloadLog(page)).slice(
baselineCommands.length,
);
expect(sendCommands.map((entry) => entry.command)).toContain("open_dm");
expect(sendCommands.map((entry) => entry.command)).not.toContain(
expect(sendCommands.map((entry) => entry.command)).toContain(
"start_managed_agent",
);
expect(sendCommands.map((entry) => entry.command)).not.toContain(
Expand Down Expand Up @@ -2554,7 +2559,18 @@ test("home inbox manage affordance opens management without leaving home", async
await expect(page).not.toHaveURL(/#\/channels\//);
});

test("members sidebar can invite and remove members", async ({ page }) => {
test("members sidebar can invite and remove managed agents", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
await openMembersSidebar(page, "general");
const initialMemberCount = await readMembersTriggerCount(page);
Expand Down Expand Up @@ -2847,9 +2863,18 @@ test("members sidebar collapses same-persona managed agents", async ({
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1);
});

test("private-channel members can add members and bots without admin", async ({
test("private-channel members can add people and managed agents without admin", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: TEST_IDENTITIES.charlie.pubkey,
name: "charlie",
status: "stopped",
},
],
});
await page.goto("/");
// secret-projects is a private (non-DM) channel where the current user is a
// plain member, not owner/admin. They should still be able to add members
Expand Down
20 changes: 10 additions & 10 deletions desktop/tests/e2e/identity-archive-hide.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,38 +166,38 @@ test.describe("NIP-IA hide archived from discovery", () => {
});

// Member-adder (ChannelMemberInviteCard) — the invite search excludes
// existing channel members, so we test in #agents where Alice is NOT a
// existing channel members, so we test in #agents where Bob is NOT a
// member (only the mock identity + Charlie are): the ONLY thing that can
// drop her from results is the archive filter. The control run (nothing
// archived) proves she would otherwise appear — guarding a vacuous green.
// drop Bob from results is the archive filter. The control run (nothing
// archived) proves he would otherwise appear — guarding a vacuous green.
test("member-adder: archived non-member is omitted from invite search", async ({
page,
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await installMockBridge(page, { archivedIdentities: [BOB_PUBKEY] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("alice");
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`),
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toHaveCount(0);
});

test("member-adder: control — non-archived non-member IS in invite search", async ({
page,
}) => {
// Same channel/query, nothing archived: Alice now appears. This is the
// companion that makes the test above non-vacuous (proves she was dropped
// Same channel/query, nothing archived: Bob now appears. This is the
// companion that makes the test above non-vacuous (proves he was dropped
// by the archive filter, not by member-exclusion or a broken search).
await installMockBridge(page, { archivedIdentities: [] });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("alice");
await page.getByTestId("channel-management-search-users").fill("bob");
await expect(
page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`),
page.getByTestId(`channel-user-search-result-${BOB_PUBKEY}`),
).toBeVisible();
});
});
Loading
Loading