Skip to content
Closed
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
46 changes: 46 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 {
buildChannelMentionCandidate,
buildTeamMentionCandidates,
formatChannelMention,
formatTeamMention,
} from "./mentionCandidates.ts";

Expand Down Expand Up @@ -56,6 +58,50 @@ function identity(personaId, displayName, overrides = {}) {
};
}

test("channel mention expands every other uniquely named channel member", () => {
const currentPubkey = "1".repeat(64);
const suggestion = buildChannelMentionCandidate(
[
identity(undefined, "Josh", {
isMember: true,
isAgent: false,
pubkey: currentPubkey,
}),
identity(undefined, "Nyx", {
isMember: true,
isAgent: true,
pubkey: "2".repeat(64),
}),
identity(undefined, "Solace", {
isMember: true,
isAgent: true,
pubkey: "3".repeat(64),
}),
identity(undefined, "Outside", {
isMember: false,
isAgent: false,
pubkey: "4".repeat(64),
}),
],
currentPubkey,
);

assert.deepEqual(suggestion, {
kind: "channel",
channelMembers: [
{ displayName: "Nyx", kind: "identity", pubkey: "2".repeat(64) },
{ displayName: "Solace", kind: "identity", pubkey: "3".repeat(64) },
],
displayName: "everyone",
isAgent: false,
isMember: false,
});
assert.equal(
formatChannelMention(suggestion.channelMembers),
"@Nyx @Solace ",
);
});

test("team mentions preserve team order and prefer concrete managed agents", () => {
const personas = [
persona("planner", "Planner"),
Expand Down
53 changes: 51 additions & 2 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,22 @@ export function appendUniqueName(current: string[], name: string): string[] {
: [...current, name];
}

export type TeamMentionMember = {
export type MentionGroupMember = {
displayName: string;
kind: "identity" | "persona";
personaId?: string;
pubkey?: string;
};

export type TeamMentionMember = MentionGroupMember;

export type MentionCandidate = {
kind: "identity" | "persona" | "team";
kind: "identity" | "persona" | "team" | "channel";
pubkey?: string;
personaId?: string;
teamId?: string;
teamMembers?: TeamMentionMember[];
channelMembers?: MentionGroupMember[];
displayName: string | null;
avatarUrl?: string | null;
isMember: boolean;
Expand Down Expand Up @@ -153,3 +156,49 @@ export function formatTeamMention(
) {
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
}

/** Build the safe client-side expansion for the current channel's members. */
export function buildChannelMentionCandidate(
candidates: readonly MentionCandidate[],
currentPubkey: string | null,
): MentionCandidate | null {
const channelMembers = candidates.flatMap((candidate) => {
if (
candidate.kind !== "identity" ||
!candidate.isMember ||
!candidate.pubkey ||
candidate.pubkey === currentPubkey
) {
return [];
}

const displayName = candidate.displayName?.trim();
return displayName
? [{ displayName, kind: "identity" as const, pubkey: candidate.pubkey }]
: [];
});
const names = new Set<string>();
if (
channelMembers.length === 0 ||
channelMembers.some((member) => {
const normalized = member.displayName.toLowerCase();
if (names.has(normalized)) return true;
names.add(normalized);
return false;
})
) {
return null;
}

return {
kind: "channel",
channelMembers,
displayName: "everyone",
isAgent: false,
isMember: false,
};
}

export function formatChannelMention(members: readonly MentionGroupMember[]) {
return `${members.map((member) => `@${member.displayName}`).join(" ")} `;
}
2 changes: 1 addition & 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" | "channel";
personaId?: string | null;
personaName?: string | null;
pubkey?: string;
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/features/messages/lib/mentionSuggestionMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ 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 {
MentionGroupMember,
TeamMentionMember,
} from "./mentionCandidates";

export type MentionSuggestionCandidate = {
kind: "identity" | "persona" | "team";
kind: "identity" | "persona" | "team" | "channel";
pubkey?: string;
personaId?: string | null;
teamId?: string;
teamMembers?: TeamMentionMember[];
channelMembers?: MentionGroupMember[];
avatarUrl?: string | null;
isAgent: boolean;
isMember: boolean;
Expand Down Expand Up @@ -43,6 +47,7 @@ export function mapMentionCandidateToSuggestion(opts: {
personaId: candidate.personaId ?? undefined,
teamId: candidate.teamId,
teamMembers: candidate.teamMembers,
channelMembers: candidate.channelMembers,
kind: candidate.kind,
displayName: label,
avatarUrl:
Expand All @@ -54,6 +59,7 @@ export function mapMentionCandidateToSuggestion(opts: {
isAgent: candidate.isAgent,
notInChannel:
candidate.kind !== "team" &&
candidate.kind !== "channel" &&
channelType !== "dm" &&
candidate.isMember === false,
ownerLabel,
Expand Down
30 changes: 24 additions & 6 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
import {
appendUniqueName,
buildChannelMentionCandidate,
buildTeamMentionCandidates,
formatChannelMention,
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
formatTeamMention,
Expand Down Expand Up @@ -439,6 +441,10 @@ export function useMentions(
() => getAdmittedAgentPubkeys(mentionCandidates),
[mentionCandidates],
);
const channelMentionCandidate = React.useMemo(
() => buildChannelMentionCandidate(mentionCandidates, currentPubkey),
[currentPubkey, mentionCandidates],
);
const mentionCandidatesWithTeams = React.useMemo(
() => [
...mentionCandidates,
Expand All @@ -447,8 +453,14 @@ export function useMentions(
personasQuery.data ?? [],
mentionCandidates,
),
...(channelMentionCandidate ? [channelMentionCandidate] : []),
],
[
channelMentionCandidate,
mentionCandidates,
personasQuery.data,
teamsQuery.data,
],
[mentionCandidates, personasQuery.data, teamsQuery.data],
);
const ownerPubkeys = React.useMemo(
() => [
Expand Down Expand Up @@ -613,15 +625,21 @@ export function useMentions(
}

const displayName = suggestion.displayName;
const teamMembers =
suggestion.kind === "team" ? suggestion.teamMembers : null;
const insertText = teamMembers
? formatTeamMention(displayName, teamMembers)
const mentionGroupMembers =
suggestion.kind === "team"
? suggestion.teamMembers
: suggestion.kind === "channel"
? suggestion.channelMembers
: null;
const insertText = mentionGroupMembers
? suggestion.kind === "team"
? formatTeamMention(displayName, mentionGroupMembers)
: formatChannelMention(mentionGroupMembers)
: `@${displayName} `;

const mentions = mentionMapRef.current;
const personaMentions = personaMentionMapRef.current;
const selectedMentions = teamMembers ?? [suggestion];
const selectedMentions = mentionGroupMembers ?? [suggestion];
for (const selected of selectedMentions) {
if (selected.kind === "persona" && selected.personaId) {
personaMentions.set(selected.displayName, selected.personaId);
Expand Down
22 changes: 18 additions & 4 deletions desktop/src/features/messages/ui/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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 {
MentionGroupMember,
TeamMentionMember,
} from "@/features/messages/lib/mentionCandidates";

import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
Expand All @@ -18,7 +21,8 @@ export type MentionSuggestion = {
personaId?: string;
teamId?: string;
teamMembers?: TeamMentionMember[];
kind?: "identity" | "persona" | "team";
channelMembers?: MentionGroupMember[];
kind?: "identity" | "persona" | "team" | "channel";
displayName: string;
avatarUrl?: string | null;
isAgent?: boolean;
Expand Down Expand Up @@ -99,6 +103,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestion.pubkey ??
(suggestion.personaId ? `persona-${suggestion.personaId}` : null) ??
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
(suggestion.kind === "channel" ? "channel-members" : null) ??
suggestion.displayName;
const agentLabel = "agent";
const hasNameCollision =
Expand All @@ -125,7 +130,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
tabIndex={-1}
type="button"
>
{suggestion.kind === "team" ? (
{suggestion.kind === "team" || suggestion.kind === "channel" ? (
<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>
Expand All @@ -142,9 +147,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
className="min-w-0 break-words font-medium leading-snug"
title={suggestion.displayName}
>
{suggestion.displayName}
{suggestion.kind === "channel"
? `@${suggestion.displayName}`
: suggestion.displayName}
</span>
{suggestion.kind === "team" ||
suggestion.kind === "channel" ||
suggestion.isAgent ||
suggestion.role ||
suggestion.ownerLabel ||
Expand All @@ -162,6 +170,12 @@ 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 === "channel" ? (
<span className="inline-flex shrink-0 items-center gap-1">
<Users aria-hidden="true" className="h-3.5 w-3.5" />
everyone · {suggestion.channelMembers?.length ?? 0}{" "}
members
</span>
) : suggestion.isAgent ? (
<span className="inline-flex shrink-0 items-center gap-1">
<Bot
Expand Down
19 changes: 19 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,25 @@ test("relay-only shared agents emit an outbound mention tag when selected", asyn
.toContain(TEST_IDENTITIES.alice.pubkey);
});

test("channel mention selects every other current member", async ({ 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("Notify @everyone");

const channelMention = composer.getByTestId(
"mention-suggestion-channel-members",
);
await expect(channelMention).toContainText("@everyone");
await expect(channelMention).toContainText("everyone · 2 members");
await channelMention.click();

const content = "Notify @alice @bob ";
await expect(input).toHaveText(content);
});

test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({
page,
}) => {
Expand Down