From af61e35c767a022139ba053c4f9c5f60024417ff Mon Sep 17 00:00:00 2001 From: npub1qm4se9q0tzayt7ctvm3v7u58k0cvvn7y8gvmlrhq6mmypjvmt7ns0h6ss5 <06eb0c940f58ba45fb0b66e2cf7287b3f0c64fc43a19bf8ee0d6f640c99b5fa7@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 11:18:00 -0400 Subject: [PATCH] feat(desktop): add @agents/@people category mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing @agents or @people in the desktop composer now suggests a virtual category entry built from the channel's identity members. Selecting it unfurls into individual @mentions of every matching member (plain text + standard per-recipient tracked mentions), so recipients and relays see ordinary mentions — no protocol changes. Follows the team-mention machinery from #1918: categories are ranked with teams, skip nameless members, are suppressed entirely on display-name collisions, and @people excludes the current user. Signed-off-by: npub1qm4se9q0tzayt7ctvm3v7u58k0cvvn7y8gvmlrhq6mmypjvmt7ns0h6ss5 <06eb0c940f58ba45fb0b66e2cf7287b3f0c64fc43a19bf8ee0d6f640c99b5fa7@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../messages/lib/mentionCandidates.test.mjs | 100 ++++++++++++++ .../messages/lib/mentionCandidates.ts | 126 +++++++++++++++++- .../features/messages/lib/mentionRanking.ts | 3 +- .../messages/lib/mentionSuggestionMapping.ts | 7 +- .../src/features/messages/lib/useMentions.ts | 26 ++-- .../messages/ui/MentionAutocomplete.tsx | 22 ++- desktop/tests/e2e/category-mentions.spec.ts | 89 +++++++++++++ 8 files changed, 349 insertions(+), 25 deletions(-) create mode 100644 desktop/tests/e2e/category-mentions.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e3745338a2..41a3f7cf53 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 355b56dfea..cf9c06d648 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildCategoryMentionCandidates, buildTeamMentionCandidates, + formatCategoryMention, formatTeamMention, } from "./mentionCandidates.ts"; @@ -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), []); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..3683fe6cad 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -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 = { @@ -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; @@ -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(); + + 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; +} diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de7..7be1d100d2 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -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; @@ -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; diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..03baad22fc 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -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; @@ -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, @@ -54,6 +56,7 @@ export function mapMentionCandidateToSuggestion(opts: { isAgent: candidate.isAgent, notInChannel: candidate.kind !== "team" && + candidate.kind !== "category" && channelType !== "dm" && candidate.isMember === false, ownerLabel, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..4e10bdb0b2 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -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"; @@ -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"; @@ -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(), @@ -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( @@ -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; diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..c421f4be83 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import { Bot, Users } from "lucide-react"; -import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; +import type { + CategoryMentionId, + TeamMentionMember, +} from "@/features/messages/lib/mentionCandidates"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; @@ -17,8 +20,9 @@ export type MentionSuggestion = { pubkey?: string; personaId?: string; teamId?: string; + categoryId?: CategoryMentionId; teamMembers?: TeamMentionMember[]; - kind?: "identity" | "persona" | "team"; + kind?: "identity" | "persona" | "team" | "category"; displayName: string; avatarUrl?: string | null; isAgent?: boolean; @@ -99,6 +103,9 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion.pubkey ?? (suggestion.personaId ? `persona-${suggestion.personaId}` : null) ?? (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? + (suggestion.categoryId + ? `category-${suggestion.categoryId}` + : null) ?? suggestion.displayName; const agentLabel = "agent"; const hasNameCollision = @@ -125,7 +132,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ tabIndex={-1} type="button" > - {suggestion.kind === "team" ? ( + {suggestion.kind === "team" || suggestion.kind === "category" ? ( @@ -145,6 +152,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ {suggestion.displayName} {suggestion.kind === "team" || + suggestion.kind === "category" || suggestion.isAgent || suggestion.role || suggestion.ownerLabel || @@ -162,6 +170,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({