Prioritize channel members in mention autocomplete (#1431)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-01 10:54:32 -07:00
committed by GitHub
co-authored by Pinky
parent 6a08a3f4c8
commit 8fb33bdbca
7 changed files with 294 additions and 92 deletions
@@ -389,9 +389,15 @@ export function ChannelScreen({
messageProfilesQuery.data?.profiles,
currentProfile,
) ?? {};
return mergeAgentNamesIntoProfiles(base, managedAgents, relayAgents);
return mergeAgentNamesIntoProfiles(
base,
managedAgents,
relayAgents,
currentPubkey,
);
}, [
currentProfile,
currentPubkey,
managedAgents,
messageProfilesQuery.data?.profiles,
relayAgents,
@@ -97,6 +97,7 @@ export function mergeAgentNamesIntoProfiles(
profiles: UserProfileLookup,
managedAgents: ManagedAgent[],
relayAgents: RelayAgent[],
currentPubkey?: string | null,
): UserProfileLookup {
const merged = { ...profiles };
for (const agent of relayAgents) {
@@ -116,6 +117,7 @@ export function mergeAgentNamesIntoProfiles(
displayName: merged[key]?.displayName || agent.name,
avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl,
nip05Handle: merged[key]?.nip05Handle ?? null,
ownerPubkey: merged[key]?.ownerPubkey ?? currentPubkey ?? null,
isAgent: true,
};
}
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import test from "node:test";
import { rankMentionCandidates } from "./mentionRanking.ts";
const CHANNEL_BRAIN_PUBKEY = "1".repeat(64);
const OTHER_BRAIN_PUBKEY = "2".repeat(64);
function candidate(overrides = {}) {
return {
kind: "identity",
displayName: "Brain",
isAgent: false,
isMember: false,
pubkey: OTHER_BRAIN_PUBKEY,
...overrides,
};
}
function rankedPubkeys(
candidates,
query = "brain",
activePersonaIds = new Set(),
) {
return rankMentionCandidates(candidates, query, activePersonaIds).map(
(item) => item.candidate.pubkey ?? `persona:${item.candidate.personaId}`,
);
}
test("rankMentionCandidates: channel members outrank runnable personas, people, and other agents", () => {
const persona = candidate({
kind: "persona",
personaId: "brain-persona",
pubkey: undefined,
});
const remoteAgent = candidate({
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
const person = candidate({
pubkey: "6".repeat(64),
});
const channelMember = candidate({
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
assert.deepEqual(
rankedPubkeys([persona, remoteAgent, person, channelMember]),
[
CHANNEL_BRAIN_PUBKEY,
"persona:brain-persona",
"6".repeat(64),
OTHER_BRAIN_PUBKEY,
],
);
});
test("rankMentionCandidates: exact and prefix quality sort within the channel-member group", () => {
const wordPrefixMember = candidate({
displayName: "The Brain",
isMember: true,
pubkey: "3".repeat(64),
});
const exactMember = candidate({
displayName: "Brain",
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const prefixMember = candidate({
displayName: "Brainiac",
isMember: true,
pubkey: "4".repeat(64),
});
assert.deepEqual(
rankedPubkeys([wordPrefixMember, exactMember, prefixMember]),
[CHANNEL_BRAIN_PUBKEY, "4".repeat(64), "3".repeat(64)],
);
});
test("rankMentionCandidates: matching secondary labels participate in ranking", () => {
const memberByHandle = candidate({
displayName: "Acme Bot",
secondaryLabel: "brain@example.com",
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const nonMemberName = candidate({
displayName: "Brain",
pubkey: OTHER_BRAIN_PUBKEY,
});
assert.deepEqual(rankedPubkeys([nonMemberName, memberByHandle]), [
CHANNEL_BRAIN_PUBKEY,
OTHER_BRAIN_PUBKEY,
]);
});
test("rankMentionCandidates: active persona-backed non-members outrank other non-member agents", () => {
const activePersonaAgent = candidate({
displayName: "Brain",
isAgent: true,
personaId: "brain-persona",
pubkey: "5".repeat(64),
});
const remoteAgent = candidate({
displayName: "Brain",
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
assert.deepEqual(
rankedPubkeys(
[remoteAgent, activePersonaAgent],
"brain",
new Set(["brain-persona"]),
),
["5".repeat(64), OTHER_BRAIN_PUBKEY],
);
});
@@ -0,0 +1,100 @@
import { normalizePubkey } from "@/shared/lib/pubkey";
export type MentionCandidateForRanking = {
displayName: string | null;
isAgent: boolean;
isMember: boolean;
kind: "identity" | "persona";
personaId?: string | null;
personaName?: string | null;
pubkey?: string;
secondaryLabel?: string | null;
};
export type RankedMentionCandidate<T extends MentionCandidateForRanking> = {
candidate: T;
groupRank: number;
label: string;
order: number;
score: number;
};
function getMentionCandidateGroupRank(
candidate: MentionCandidateForRanking,
activePersonaIds: ReadonlySet<string>,
) {
if (candidate.isMember) return 0;
const isRunnablePersona =
candidate.kind === "persona" ||
(candidate.personaId ? activePersonaIds.has(candidate.personaId) : false);
if (isRunnablePersona) return 1;
if (!candidate.isAgent) return 2;
return 3;
}
function scoreMentionCandidateLabel(
label: string,
lowerQuery: string,
): number | null {
const lower = label.toLowerCase();
if (lower === lowerQuery) return 0;
if (lower.startsWith(lowerQuery)) return 1;
const words = lower.split(/[\s\-_]+/).filter(Boolean);
if (words.some((word) => word === lowerQuery)) return 2;
if (words.some((word) => word.startsWith(lowerQuery))) return 3;
return null;
}
export function rankMentionCandidates<T extends MentionCandidateForRanking>(
candidates: readonly T[],
query: string,
activePersonaIds: ReadonlySet<string> = new Set(),
): RankedMentionCandidate<T>[] {
const lowerQuery = query.toLowerCase();
return candidates
.map((candidate, order) => {
const pubkeyLower = candidate.pubkey
? normalizePubkey(candidate.pubkey)
: "";
const label =
candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
const groupRank = getMentionCandidateGroupRank(
candidate,
activePersonaIds,
);
const labelScores = [
candidate.displayName,
candidate.personaName,
candidate.secondaryLabel,
]
.map((value) =>
value ? scoreMentionCandidateLabel(value, lowerQuery) : null,
)
.filter((score): score is number => score !== null);
const labelScore =
labelScores.length > 0 ? Math.min(...labelScores) : null;
const pubkeyScore = candidate.pubkey
? pubkeyLower.startsWith(lowerQuery)
? 4
: pubkeyLower.includes(lowerQuery)
? 5
: null
: null;
const score = labelScore !== null ? labelScore : pubkeyScore;
return { candidate, groupRank, label, order, score };
})
.filter((item): item is RankedMentionCandidate<T> => item.score !== null)
.sort(
(a, b) =>
a.groupRank - b.groupRank || a.score - b.score || a.order - b.order,
);
}
@@ -35,6 +35,7 @@ import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { hasMention } from "./hasMention";
import { rankMentionCandidates } from "./mentionRanking";
const MENTION_DEBOUNCE_MS = 120;
const MENTION_SUGGESTION_LIMIT = 50;
@@ -103,13 +104,22 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) {
function formatOwnerLabel(
ownerPubkey: string | null | undefined,
currentPubkey: string | null | undefined,
ownerProfiles?: UserProfileLookup,
) {
if (!ownerPubkey) {
return null;
}
const owner = ownerProfiles?.[normalizePubkey(ownerPubkey)];
const normalizedOwnerPubkey = normalizePubkey(ownerPubkey);
if (
currentPubkey &&
normalizedOwnerPubkey === normalizePubkey(currentPubkey)
) {
return "you";
}
const owner = ownerProfiles?.[normalizedOwnerPubkey];
return (
owner?.displayName?.trim() ||
owner?.nip05Handle?.trim() ||
@@ -309,7 +319,13 @@ export function useMentions(
role: current.role ?? candidate.role ?? null,
secondaryLabel:
current.secondaryLabel ?? candidate.secondaryLabel ?? null,
ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null,
ownerPubkey:
current.ownerPubkey ??
candidate.ownerPubkey ??
(candidate.isAgent && candidate.pubkey
? profiles?.[pubkey]?.ownerPubkey
: null) ??
null,
isManagedAgent: current.isManagedAgent || candidate.isManagedAgent,
});
};
@@ -339,6 +355,7 @@ export function useMentions(
member.role === "bot" ||
managedAgentNamesByPubkey.has(pubkey) ||
relayAgentNamesByPubkey.has(pubkey),
ownerPubkey: profile?.ownerPubkey ?? null,
personaName: personaNameByPubkey.get(pubkey) ?? null,
role: member.role,
secondaryLabel:
@@ -544,92 +561,44 @@ export function useMentions(
return [];
}
const lowerQuery = mentionQuery.toLowerCase();
// Score a label against the query using word-boundary prefix matching.
// Returns 0 if the full label starts with the query (best), 1 if any
// word within the label starts with the query, or null if there's no
// match. No arbitrary substring matching — standard for mention UX.
const scoreLabel = (label: string): number | null => {
const lower = label.toLowerCase();
if (lower.startsWith(lowerQuery)) return 0;
const words = lower.split(/[\s\-_]+/).filter(Boolean);
if (words.some((word) => word.startsWith(lowerQuery))) return 1;
return null;
};
return mentionCandidates
.map((candidate, order) => {
const pubkeyLower = candidate.pubkey
? normalizePubkey(candidate.pubkey)
: "";
const label =
candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
const groupRank =
candidate.kind === "persona" ||
(candidate.personaId
? activePersonaIds.has(candidate.personaId)
: false)
? 0
: candidate.isMember
? 1
: 2;
const labelScores = [
candidate.displayName,
candidate.personaName,
candidate.secondaryLabel,
]
.map((value) => (value ? scoreLabel(value) : null))
.filter((score): score is number => score !== null);
const labelScore =
labelScores.length > 0 ? Math.min(...labelScores) : null;
const pubkeyScore = candidate.pubkey
? pubkeyLower.startsWith(lowerQuery)
? 3
: pubkeyLower.includes(lowerQuery)
? 4
: null
: null;
const score = labelScore !== null ? labelScore : pubkeyScore;
return rankMentionCandidates(
mentionCandidates,
mentionQuery,
activePersonaIds,
)
.slice(0, MENTION_SUGGESTION_LIMIT)
.map(({ candidate, label }) => {
const ownerLabel = candidate.isAgent
? formatOwnerLabel(
candidate.ownerPubkey,
currentPubkey,
ownerProfilesQuery.data?.profiles,
)
: null;
const notInChannel =
options?.channelType !== "dm" && candidate.isMember === false;
return { candidate, groupRank, label, order, ownerLabel, score };
})
.filter(
(item): item is typeof item & { score: number } => item.score !== null,
)
.sort(
(a, b) =>
a.groupRank - b.groupRank || a.score - b.score || a.order - b.order,
)
.slice(0, MENTION_SUGGESTION_LIMIT)
.map(({ candidate, label, ownerLabel }) => ({
pubkey: candidate.pubkey,
personaId: candidate.personaId,
kind: candidate.kind,
displayName: label,
avatarUrl:
candidate.avatarUrl ??
(candidate.pubkey
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
: null) ??
null,
isAgent: candidate.isAgent,
notInChannel:
options?.channelType !== "dm" && candidate.isMember === false,
ownerLabel,
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
}));
return {
pubkey: candidate.pubkey,
personaId: candidate.personaId,
kind: candidate.kind,
displayName: label,
avatarUrl:
candidate.avatarUrl ??
(candidate.pubkey
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
: null) ??
null,
isAgent: candidate.isAgent,
notInChannel,
ownerLabel,
role:
!candidate.isAgent && candidate.role === "admin" ? "admin" : null,
};
});
}, [
activePersonaIds,
currentPubkey,
mentionCandidates,
mentionQuery,
options?.channelType,
@@ -138,14 +138,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
<span
className="min-w-0 truncate"
title={
suggestion.ownerLabel
? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}`
: "not in channel"
suggestion.ownerLabel && suggestion.notInChannel
? `owned by ${suggestion.ownerLabel} · not in channel`
: suggestion.ownerLabel
? `owned by ${suggestion.ownerLabel}`
: "not in channel"
}
>
{suggestion.ownerLabel
? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}`
: "not in channel"}
{suggestion.ownerLabel && suggestion.notInChannel
? `owned by ${suggestion.ownerLabel} · not in channel`
: suggestion.ownerLabel
? `owned by ${suggestion.ownerLabel}`
: "not in channel"}
</span>
) : null}
</span>
+5 -6
View File
@@ -160,7 +160,7 @@ async function expectAgentProfileMessageOnly(
).toHaveCount(0);
}
test("@ trigger shows unified autocomplete with agents first", async ({
test("@ trigger prioritizes channel members before runnable personas and other agents", async ({
page,
}) => {
await installMockBridge(page, {
@@ -205,10 +205,9 @@ test("@ trigger shows unified autocomplete with agents first", async ({
expect(bobIndex).toBeGreaterThanOrEqual(0);
expect(charlieIndex).toBeGreaterThanOrEqual(0);
expect(outsiderIndex).toEqual(-1);
expect(fizzIndex).toBeLessThan(aliceIndex);
expect(fizzIndex).toBeLessThan(bobIndex);
expect(aliceIndex).toBeLessThan(charlieIndex);
expect(bobIndex).toBeLessThan(charlieIndex);
expect(aliceIndex).toBeLessThan(fizzIndex);
expect(bobIndex).toBeLessThan(fizzIndex);
expect(fizzIndex).toBeLessThan(charlieIndex);
});
test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({
@@ -274,7 +273,7 @@ test("thread autocomplete keeps multiple long names readable in a narrow panel",
row.getByTestId("mention-suggestion-avatar-fallback"),
).toBeVisible();
await expect(row.getByText("agent")).toBeVisible();
await expect(row.getByText(/owned by npub1mock/)).toBeVisible();
await expect(row.getByText("owned by you")).toBeVisible();
await expect(row.getByText(name)).not.toHaveCSS(
"text-overflow",