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<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;
+}
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" ? (
                 <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
                   <Users aria-hidden="true" className="h-4 w-4" />
                 </span>
@@ -145,6 +152,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
                   {suggestion.displayName}
                 </span>
                 {suggestion.kind === "team" ||
+                suggestion.kind === "category" ||
                 suggestion.isAgent ||
                 suggestion.role ||
                 suggestion.ownerLabel ||
@@ -162,6 +170,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
                         <Users aria-hidden="true" className="h-3.5 w-3.5" />
                         team · {suggestion.teamMembers?.length ?? 0} agents
                       </span>
+                    ) : suggestion.kind === "category" ? (
+                      <span className="inline-flex shrink-0 items-center gap-1">
+                        <Users aria-hidden="true" className="h-3.5 w-3.5" />
+                        category · {suggestion.teamMembers?.length ?? 0}{" "}
+                        {suggestion.categoryId === "agents"
+                          ? "agents in this channel"
+                          : "people in this channel"}
+                      </span>
                     ) : suggestion.isAgent ? (
                       <span className="inline-flex shrink-0 items-center gap-1">
                         <Bot
diff --git a/desktop/tests/e2e/category-mentions.spec.ts b/desktop/tests/e2e/category-mentions.spec.ts
new file mode 100644
index 0000000000..b85ce4ba72
--- /dev/null
+++ b/desktop/tests/e2e/category-mentions.spec.ts
@@ -0,0 +1,89 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
+
+const SHOTS = "test-results/category-mentions";
+
+test.use({ viewport: { width: 1280, height: 720 } });
+
+test("@agents unfurls into every agent in the channel", async ({ page }) => {
+  await installMockBridge(page, {
+    managedAgents: [
+      {
+        pubkey: TEST_IDENTITIES.charlie.pubkey,
+        name: "charlie",
+        status: "running",
+        channelNames: ["general"],
+      },
+      {
+        pubkey: "8".repeat(64),
+        name: "Scout",
+        status: "running",
+        channelNames: ["general"],
+      },
+    ],
+  });
+
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+
+  const composer = page.getByTestId("message-composer");
+  const input = composer.getByTestId("message-input");
+  await input.fill("Status check @agent");
+
+  const autocomplete = composer.getByTestId("mention-autocomplete");
+  const categoryRow = autocomplete.getByTestId(
+    "mention-suggestion-category-agents",
+  );
+  await expect(categoryRow).toContainText("agents");
+  await expect(categoryRow).toContainText("category · 2 agents");
+
+  await waitForAnimations(page);
+  await page.screenshot({
+    path: `${SHOTS}/01-agents-category-suggestion.png`,
+    clip: { x: 240, y: 380, width: 800, height: 320 },
+  });
+
+  await input.press("Enter");
+  await expect
+    .poll(() => input.evaluate((element) => element.textContent))
+    .toContain("Status check @charlie @Scout");
+  await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
+
+  await waitForAnimations(page);
+  await page.screenshot({
+    path: `${SHOTS}/02-unfurled-agent-members.png`,
+    clip: { x: 240, y: 480, width: 800, height: 220 },
+  });
+});
+
+test("@people unfurls into human members and excludes the sender", async ({
+  page,
+}) => {
+  await installMockBridge(page);
+
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+
+  const composer = page.getByTestId("message-composer");
+  const input = composer.getByTestId("message-input");
+  await input.fill("Heads up @peopl");
+
+  const autocomplete = composer.getByTestId("mention-autocomplete");
+  const categoryRow = autocomplete.getByTestId(
+    "mention-suggestion-category-people",
+  );
+  await expect(categoryRow).toContainText("people");
+  await expect(categoryRow).toContainText("people in this channel");
+
+  await input.press("Enter");
+  await expect
+    .poll(() => input.evaluate((element) => element.textContent))
+    .toContain("Heads up @bob");
+
+  const text = await input.evaluate((element) => element.textContent);
+  expect(text).not.toContain("@agents");
+  // The sender never appears in their own @people unfurl.
+  expect(text?.match(/@/g)?.length).toBe(1);
+});
