Skip to content
Open
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default defineConfig({
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
"**/category-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
Expand Down
100 changes: 100 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
buildCategoryMentionCandidates,
buildTeamMentionCandidates,
formatCategoryMention,
formatTeamMention,
} from "./mentionCandidates.ts";

Expand Down Expand Up @@ -170,3 +172,101 @@ test("teams with identity and persona display-name collisions are not suggested"
[],
);
});

function member(displayName, overrides = {}) {
return {
kind: "identity",
displayName,
isAgent: false,
isMember: true,
pubkey: "a".repeat(64),
...overrides,
};
}

test("category mentions split channel members into agents and people", () => {
const me = "f".repeat(64);
const candidates = [
member("Me", { pubkey: me }),
member("Ada", { pubkey: "1".repeat(64) }),
member("Scout", { isAgent: true, pubkey: "2".repeat(64) }),
member("Helper", { isAgent: true, pubkey: "3".repeat(64) }),
member("Outsider", { isMember: false, pubkey: "4".repeat(64) }),
member("Roaming Bot", {
isAgent: true,
isMember: false,
pubkey: "5".repeat(64),
}),
];

const suggestions = buildCategoryMentionCandidates(candidates, me);

assert.deepEqual(
suggestions.map((suggestion) => [
suggestion.categoryId,
suggestion.teamMembers.map((m) => m.displayName),
]),
[
["agents", ["Scout", "Helper"]],
["people", ["Ada"]],
],
);
assert.equal(suggestions[0].kind, "category");
assert.equal(suggestions[0].isAgent, true);
assert.equal(suggestions[1].isAgent, false);
assert.equal(
formatCategoryMention(suggestions[0].teamMembers),
"@Scout @Helper ",
);
});

test("category mentions skip nameless members and empty categories", () => {
const candidates = [
member(null, { isAgent: true, pubkey: "1".repeat(64) }),
member(" ", { isAgent: true, pubkey: "2".repeat(64) }),
member("Ada", { pubkey: "3".repeat(64) }),
];

assert.deepEqual(
buildCategoryMentionCandidates(candidates, null).map(
(suggestion) => suggestion.categoryId,
),
["people"],
);
});

test("a category with duplicate display names is not suggested", () => {
const candidates = [
member("Scout", { isAgent: true, pubkey: "1".repeat(64) }),
member("scout", { isAgent: true, pubkey: "2".repeat(64) }),
member("Ada", { pubkey: "3".repeat(64) }),
];

assert.deepEqual(
buildCategoryMentionCandidates(candidates, null).map(
(suggestion) => suggestion.categoryId,
),
["people"],
);
});

test("category mentions ignore team and persona candidates", () => {
const candidates = [
{
kind: "team",
displayName: "Launch Team",
isAgent: true,
isMember: false,
teamMembers: [],
},
{
kind: "persona",
displayName: "Planner",
isAgent: true,
isMember: true,
personaId: "planner",
},
];

assert.deepEqual(buildCategoryMentionCandidates(candidates, null), []);
});
126 changes: 124 additions & 2 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types";
import type {
AgentPersona,
AgentTeam,
ChannelRole,
UserSearchResult,
} from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";

export type TeamMentionMember = {
Expand All @@ -9,11 +14,14 @@ export type TeamMentionMember = {
pubkey?: string;
};

export type CategoryMentionId = "agents" | "people";

export type MentionCandidate = {
kind: "identity" | "persona" | "team";
kind: "identity" | "persona" | "team" | "category";
pubkey?: string;
personaId?: string;
teamId?: string;
categoryId?: CategoryMentionId;
teamMembers?: TeamMentionMember[];
displayName: string | null;
avatarUrl?: string | null;
Expand Down Expand Up @@ -130,3 +138,117 @@ export function formatTeamMention(
) {
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
}

/**
* Build the virtual `@agents` / `@people` autocomplete entries from the
* channel's current membership. Selecting one unfurls into individual
* mentions of every matching member — plain `@Name` text and standard
* per-recipient tags, so recipients and relays see ordinary mentions.
*
* Mirrors team-mention safety rules: a category is omitted entirely when
* two of its members share a display name (the mention map is keyed by
* name, so a collision would silently drop one of them). Members without
* a display name cannot be mentioned by name and are skipped. `@people`
* excludes the current user — you don't need to notify yourself.
*/
export function buildCategoryMentionCandidates(
candidates: readonly MentionCandidate[],
currentPubkey?: string | null,
): MentionCandidate[] {
const groups: Array<{
categoryId: CategoryMentionId;
matches: (candidate: MentionCandidate) => boolean;
}> = [
{
categoryId: "agents",
matches: (candidate) => candidate.isAgent === true,
},
{
categoryId: "people",
matches: (candidate) =>
candidate.isAgent !== true &&
(!currentPubkey || candidate.pubkey !== currentPubkey),
},
];

return groups.flatMap(({ categoryId, matches }) => {
const members: TeamMentionMember[] = [];
const mentionNames = new Set<string>();

for (const candidate of candidates) {
if (candidate.kind !== "identity") continue;
if (!candidate.isMember || !candidate.pubkey) continue;
if (!matches(candidate)) continue;

const displayName = candidate.displayName?.trim();
if (!displayName) continue;

const mentionName = displayName.toLowerCase();
if (mentionNames.has(mentionName)) return [];
mentionNames.add(mentionName);

members.push({
displayName,
kind: "identity",
personaId: candidate.personaId,
pubkey: candidate.pubkey,
});
}

if (members.length === 0) return [];

return [
{
kind: "category" as const,
categoryId,
teamMembers: members,
displayName: categoryId,
isMember: false,
isAgent: categoryId === "agents",
},
];
});
}

export function formatCategoryMention(members: readonly TeamMentionMember[]) {
return `${members.map((member) => `@${member.displayName}`).join(" ")} `;
}

/**
* Team and category suggestions share the unfurl path: both expand into the
* individually tracked mentions carried in `teamMembers`.
*/
export function groupMentionMembers(suggestion: {
kind?: "identity" | "persona" | "team" | "category";
teamMembers?: TeamMentionMember[];
}): TeamMentionMember[] | null {
return (suggestion.kind === "team" || suggestion.kind === "category") &&
suggestion.teamMembers
? suggestion.teamMembers
: null;
}

export function formatGroupMention(
suggestion: {
kind?: "identity" | "persona" | "team" | "category";
displayName: string;
},
members: readonly TeamMentionMember[],
) {
return suggestion.kind === "category"
? formatCategoryMention(members)
: formatTeamMention(suggestion.displayName, members);
}

export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}

export function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
3 changes: 2 additions & 1 deletion desktop/src/features/messages/lib/mentionRanking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type MentionCandidateForRanking = {
displayName: string | null;
isAgent: boolean;
isMember: boolean;
kind: "identity" | "persona" | "team";
kind: "identity" | "persona" | "team" | "category";
personaId?: string | null;
personaName?: string | null;
pubkey?: string;
Expand All @@ -27,6 +27,7 @@ function getMentionCandidateGroupRank(

const isRunnablePersona =
candidate.kind === "team" ||
candidate.kind === "category" ||
candidate.kind === "persona" ||
(candidate.personaId ? activePersonaIds.has(candidate.personaId) : false);
if (isRunnablePersona) return 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import type { ChannelRole, ChannelType } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { TeamMentionMember } from "./mentionCandidates";
import type { CategoryMentionId, TeamMentionMember } from "./mentionCandidates";

export type MentionSuggestionCandidate = {
kind: "identity" | "persona" | "team";
kind: "identity" | "persona" | "team" | "category";
pubkey?: string;
personaId?: string | null;
teamId?: string;
categoryId?: CategoryMentionId;
teamMembers?: TeamMentionMember[];
avatarUrl?: string | null;
isAgent: boolean;
Expand Down Expand Up @@ -42,6 +43,7 @@ export function mapMentionCandidateToSuggestion(opts: {
pubkey: candidate.pubkey,
personaId: candidate.personaId ?? undefined,
teamId: candidate.teamId,
categoryId: candidate.categoryId,
teamMembers: candidate.teamMembers,
kind: candidate.kind,
displayName: label,
Expand All @@ -54,6 +56,7 @@ export function mapMentionCandidateToSuggestion(opts: {
isAgent: candidate.isAgent,
notInChannel:
candidate.kind !== "team" &&
candidate.kind !== "category" &&
channelType !== "dm" &&
candidate.isMember === false,
ownerLabel,
Expand Down
26 changes: 9 additions & 17 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type {
AgentPersona,
ChannelMember,
ChannelType,
UserSearchResult,
} from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
Expand All @@ -41,9 +40,13 @@ import { useDraftMentionRouting } from "./useDraftMentionRouting";
import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
import {
buildCategoryMentionCandidates,
buildTeamMentionCandidates,
formatTeamMention,
formatGroupMention,
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
globalSearchIdentityKey,
groupMentionMembers,
type MentionCandidate,
mentionCandidateLabel,
} from "./mentionCandidates";
Expand All @@ -56,17 +59,6 @@ export type PersonaMentionTarget = {
type UseMentionsOptions = {
channelType?: ChannelType | null;
};
function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
Expand Down Expand Up @@ -439,8 +431,9 @@ export function useMentions(
personasQuery.data ?? [],
mentionCandidates,
),
...buildCategoryMentionCandidates(mentionCandidates, currentPubkey),
],
[mentionCandidates, personasQuery.data, teamsQuery.data],
[currentPubkey, mentionCandidates, personasQuery.data, teamsQuery.data],
);

const ownerPubkeys = React.useMemo(
Expand Down Expand Up @@ -615,10 +608,9 @@ export function useMentions(
}

const displayName = suggestion.displayName;
const teamMembers =
suggestion.kind === "team" ? suggestion.teamMembers : null;
const teamMembers = groupMentionMembers(suggestion);
const insertText = teamMembers
? formatTeamMention(displayName, teamMembers)
? formatGroupMention(suggestion, teamMembers)
: `@${displayName} `;

const mentions = mentionMapRef.current;
Expand Down
Loading
Loading