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
9 changes: 6 additions & 3 deletions src/bundled/mentions/MentionCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useSyncExternalStore,
} from "react";
import { useTeamMentions } from "./use-team-mentions";
import { useMentionChoices } from "./use-mention-choices";
import { MENTION_ROWS, useMentionChoices } from "./use-mention-choices";
import type { ComposerCompletionProps } from "../../features/conversation/contracts";
import type { ComposerSession } from "../../features/messages/composer-session";
import { Avatar } from "../../shared/design-system/ui/Avatar";
Expand Down Expand Up @@ -287,11 +287,14 @@ export function MentionCompletion({
: [
...available.slice(
0,
Math.min(matching.length, 50 - matchingTeams.length),
Math.min(matching.length, MENTION_ROWS - matchingTeams.length),
),
...available.slice(matching.length),
];
const next = [...ids, ...additions.map((item) => item.id)].slice(0, 50);
const next = [...ids, ...additions.map((item) => item.id)].slice(
0,
MENTION_ROWS,
);
shown.current = { session, key, ids: next };
const byId = new Map(available.map((item) => [item.id, item]));
// Space is undecided, not declined, only while evidence is still arriving:
Expand Down
37 changes: 37 additions & 0 deletions src/bundled/mentions/mention-ranking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,43 @@ it("orders mixed people and agents the same way from every input order", () => {
]);
});

it("a limit keeps the first rows of the full order", () => {
// Namesake agents, people, members, outsiders and equal labels, so every
// ranking key decides some pair.
const names = ["Honey", "Honey Bee", "honey", "Hank", "Bea", "Zed"];
const rows = Array.from({ length: 240 }, (_, i) => {
const name = names[i % names.length] ?? "";
const key = i.toString(16).padStart(64, "0");
return {
...choice("0", name, {
agent: i % 3 === 0,
owned: i % 5 === 0,
managed: i % 7 === 0,
member: i % 4 !== 0,
...(i % 11 ? {} : { elsewhere: "Other" }),
label: i % 2 ? `${name} · ${key.slice(-4)}` : name,
aliases: i % 13 ? [name] : [],
}),
recipient: { pubkey: key, name },
};
});
const history = new Map(
rows.flatMap((c, i) => (i % 9 ? [] : [[c.recipient.pubkey, i]])),
);
const presence = (key: string) =>
["online", "away", "unknown"][Number.parseInt(key.slice(-1), 16) % 3] ??
"unknown";
for (const query of ["", "h", "honey", "honey b", "bea", "nobody"]) {
const full = rankMentions(rows, query, history, presence);
for (const limit of [0, 1, 7, 50, full.length, full.length + 1])
expect(rankMentions(rows, query, history, presence, limit)).toEqual(
full.slice(0, limit),
);
}
// A bare `@` has no exact or continuing name.
expect(continuingNames(rows, " ")).toEqual([]);
expect(exactMention(rows, "")).toBeUndefined();
});
it("a known longer name continues the query, except an identity's own beside its exact name", () => {
const longer = choice("1", "Mira Example");
expect(continuedMention([longer], "mira")).toBe(true);
Expand Down
145 changes: 95 additions & 50 deletions src/bundled/mentions/mention-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,22 @@ export type MentionChoice = ReturnType<typeof mentionCandidates>[number] & {
};
const normalized = normalizeName;
const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const nameMatch = (text: string, query: string) =>
query ? (matchPerson(text, query)?.tier ?? Infinity) : 0;
function baseMatch(choice: MentionChoice, query: string) {
return Math.min(
...choice.aliases.map((name) => nameMatch(name, normalized(query))),
);
}
export function mentionMatch(choice: MentionChoice, query: string) {
const needle = normalized(query);
if (!needle) return 0;
const nameMatch = (text: string, needle: string) =>
needle ? (matchPerson(text, needle)?.tier ?? Infinity) : 0;
const aliasMatch = (choice: MentionChoice, needle: string) =>
Math.min(...choice.aliases.map((name) => nameMatch(name, needle)));
/** `mentionMatch` for an already normalized, non-empty needle. */
function labelMatch(choice: MentionChoice, needle: string) {
// No known name means the displayed label is only an identity fallback.
if (!choice.aliases.length) return Infinity;
// Rank what the user sees before other real names for the same identity.
const visible = nameMatch(choice.label, needle);
if (Number.isFinite(visible)) return visible;
return 4 + baseMatch(choice, query);
return 4 + aliasMatch(choice, needle);
}
export function mentionMatch(choice: MentionChoice, query: string) {
const needle = normalized(query);
return needle ? labelMatch(choice, needle) : 0;
}
// One sorted index of name starts per immutable choice list. Choices with a
// name too long to index are always kept for ranking to match directly.
Expand Down Expand Up @@ -86,59 +86,102 @@ export function matchingMentions(
.sort((a, b) => a - b)
.flatMap((owner) => choices[owner] ?? []);
}
type Ranked = {
c: MentionChoice;
tier: readonly number[];
label: string;
block: string;
blockLabel: string;
recent: number;
online: number;
};
const byRank = (a: Ranked, b: Ranked) => {
for (let i = 0; i < a.tier.length; i += 1) {
const d = (a.tier[i] ?? 0) - (b.tier[i] ?? 0);
if (d) return d;
}
return (
compare(a.blockLabel, b.blockLabel) ||
compare(a.block, b.block) ||
b.recent - a.recent ||
Number(b.c.managed) - Number(a.c.managed) ||
a.online - b.online ||
compare(a.label, b.label) ||
compare(a.c.recipient.pubkey, b.c.recipient.pubkey)
);
};
/**
* The first `limit` of `items` in `order`, as a stable sort would give them.
* Most items lose to the current last kept item in one comparison, so taking
* the top 50 of n costs about n comparisons instead of a full n log n sort.
*/
function first<T>(items: T[], order: (a: T, b: T) => number, limit: number) {
if (limit >= items.length) return items.sort(order);
const kept: T[] = [];
for (const item of items) {
const last = kept[kept.length - 1];
if (kept.length === limit && last !== undefined && order(item, last) >= 0)
continue;
// After every equal kept item, as a stable sort keeps input order.
let low = 0;
let high = kept.length;
while (low < high) {
const middle = (low + high) >> 1;
if (order(item, kept[middle] as T) < 0) high = middle;
else low = middle + 1;
}
kept.splice(low, 0, item);
if (kept.length > limit) kept.pop();
}
return kept;
}
/**
* Sorts by per-choice keys, so the order is transitive for any mix of people
* and agents. Another community's agents follow everyone here. After match quality, your own agents come first. Agents that
* share a name form one block at its first visible label; recency, managed,
* and presence order only that block.
* and presence order only that block. With `limit` (a whole number, 0 or
* more), only the first `limit` choices of that order are returned.
*/
export function rankMentions(
choices: readonly MentionChoice[],
query: string,
history?: ReadonlyMap<string, number>,
presence: (key: string) => string = () => "unknown",
limit = Infinity,
) {
const keyed = choices
.filter((c) => Number.isFinite(mentionMatch(c, query)))
.map((c) => {
const tier = [
c.member ? 0 : 1,
c.elsewhere ? 1 : 0,
mentionMatch(c, query),
baseMatch(c, query),
c.agent && c.owned ? 0 : 1,
];
return {
c,
tier,
label: normalized(c.label),
// People never join a block. On an equal label they sort first.
block: c.agent
? `1${JSON.stringify([tier, normalized(c.recipient.name)])}`
: `0${c.recipient.pubkey}`,
recent: history?.get(c.recipient.pubkey) ?? 0,
online: { online: 0, away: 1 }[presence(c.recipient.pubkey)] ?? 2,
};
const needle = normalized(query);
const keyed: Ranked[] = [];
for (const c of choices) {
const match = needle ? labelMatch(c, needle) : 0;
if (!Number.isFinite(match)) continue;
const tier = [
c.member ? 0 : 1,
c.elsewhere ? 1 : 0,
match,
aliasMatch(c, needle),
c.agent && c.owned ? 0 : 1,
];
keyed.push({
c,
tier,
label: normalized(c.label),
// People never join a block. On an equal label they sort first.
block: c.agent
? `1${JSON.stringify([tier, normalized(c.recipient.name)])}`
: `0${c.recipient.pubkey}`,
blockLabel: "",
recent: history?.get(c.recipient.pubkey) ?? 0,
online: { online: 0, away: 1 }[presence(c.recipient.pubkey)] ?? 2,
});
const first = new Map<string, string>();
}
const blockLabels = new Map<string, string>();
for (const k of keyed) {
const label = first.get(k.block);
if (label === undefined || k.label < label) first.set(k.block, k.label);
const label = blockLabels.get(k.block);
if (label === undefined || k.label < label)
blockLabels.set(k.block, k.label);
}
const blockLabel = (k: (typeof keyed)[number]) => first.get(k.block) ?? "";
return keyed
.sort(
(a, b) =>
a.tier.reduce((d, value, i) => d || value - (b.tier[i] ?? 0), 0) ||
compare(blockLabel(a), blockLabel(b)) ||
compare(a.block, b.block) ||
b.recent - a.recent ||
Number(b.c.managed) - Number(a.c.managed) ||
a.online - b.online ||
compare(a.label, b.label) ||
compare(a.c.recipient.pubkey, b.c.recipient.pubkey),
)
.map((k) => k.c);
for (const k of keyed) k.blockLabel = blockLabels.get(k.block) ?? "";
return first(keyed, byRank, limit).map((k) => k.c);
}
const choiceNames = (c: MentionChoice) => [...c.aliases, c.label];
/**
Expand All @@ -156,6 +199,8 @@ export function continuingNames(
) {
const needle = normalized(query);
const names = new Map<string, string>();
// No name continues an empty query; skip folding every name at a bare `@`.
if (!needle) return [];
for (const c of choices) {
// `Example Agent` never keeps `example` from selecting the agent that is
// also named `Example`.
Expand Down
17 changes: 16 additions & 1 deletion src/bundled/mentions/team-mentions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MentionCompletion } from "./MentionCompletion";
import { MentionPicker } from "./MentionPicker";
import { mentionQuery } from "./mention-query";
import { useMentionChoices } from "./use-mention-choices";
import { MENTION_ROWS, useMentionChoices } from "./use-mention-choices";
import { useTeamMentions } from "./use-team-mentions";
import { createAgentLibrary } from "../../features/agents/library";
import { createAgentChoices } from "../../features/agents/choices";
Expand Down Expand Up @@ -536,6 +536,21 @@ it.each([false, true])(
},
);

it("more members than the shown rows rank only the first rows", async () => {
const h = fixture({ count: MENTION_ROWS + 1, team: 0 });
const view = renderHook(() =>
useMentionChoices(h.session, "parent", false, "", "open", true),
);
try {
await waitFor(() =>
expect(view.result.current.choices).toHaveLength(MENTION_ROWS),
);
} finally {
view.unmount();
h.library.dispose();
}
});

it("reserves room for teams ready when the first 50-choice budget installs", async () => {
const h = fixture({ count: 50 });
const menu = inline(h.session);
Expand Down
25 changes: 15 additions & 10 deletions src/bundled/mentions/use-mention-choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
type Person = VerifiedPerson<
ReturnType<typeof useMentionDirectory>["people"][number]
>;
/** The chooser shows at most this many member and agent rows. */
export const MENTION_ROWS = 50;
const activityFields = new Set(["preview", "lastActivityAt"]);
/** Equal but for the preview and activity time a new message changes. */
function sameMentionFields(a: ChannelSummary, b: ChannelSummary) {
Expand Down Expand Up @@ -425,27 +427,30 @@ export function useMentionChoices(
[owners, profiles, session.viewer],
);
const matches = matchingMentions(candidates, query);
// Ranking needs only the rows that can show.
const rank = (choices: readonly MentionChoice[]) =>
rankMentions(
choices,
query,
mentionHistory(session, channelId),
(key) => session.presence?.status(key) ?? "unknown",
MENTION_ROWS,
);
const outside = open
? rank(matches.filter((c) => !local.has(c.recipient.pubkey)))
: [];
// Ranked only when read: a full set of installed rows never needs outside choices.
let outsideRows: readonly MentionChoice[] | undefined;
const outside = () =>
(outsideRows ??= open
? rank(matches.filter((c) => !local.has(c.recipient.pubkey)))
: []);
// A member a completed read found no profile for has no name to be a namesake.
const missingNames =
open &&
channel?.members?.some((key) => !profiles.has(key) && !unnamed?.has(key));
if (open && !rows && !membersPending) {
const ranked = rank(
matches.filter((c) => local.has(c.recipient.pubkey)),
).slice(0, 50);
const ranked = rank(matches.filter((c) => local.has(c.recipient.pubkey)));
if (
ranked.length ||
outside.length ||
outside().length ||
(!pending &&
!missingNames &&
(!invite || !agents.pending) &&
Expand All @@ -457,13 +462,13 @@ export function useMentionChoices(
install({ session, key, rows, people: none });
}
}
if (rows && rows.length < 50) {
if (rows && rows.length < MENTION_ROWS) {
// Append-only: late invited agents and directory pages never move shown rows.
// Member-list changes keep their installed set until the query changes.
const shown = new Set(rows.map((row) => row.recipient.pubkey));
const added = (invite ? rank(matches.filter((c) => c.agent)) : outside)
const added = (invite ? rank(matches.filter((c) => c.agent)) : outside())
.filter((c) => !shown.has(c.recipient.pubkey))
.slice(0, 50 - rows.length);
.slice(0, MENTION_ROWS - rows.length);
if (added.length) {
const addedKeys = new Set(added.map((c) => c.recipient.pubkey));
rows = [...rows, ...added];
Expand Down
Loading