mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(desktop): rank next mention snapshot by admitted choices
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
getMentionSelectionHistory,
|
||||
resetMentionSelectionHistory,
|
||||
} from "../messages/lib/mentionSelectionHistory.ts";
|
||||
// Admission against existing root query evidence, without membership freshness production.
|
||||
// Real mention and picker hooks; Tauri policy/classification are fixture evidence.
|
||||
import assert from "node:assert/strict";
|
||||
@@ -246,6 +250,7 @@ async function setup(overrides = {}) {
|
||||
}
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root.unmount());
|
||||
resetMentionSelectionHistory();
|
||||
client?.clear();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
@@ -326,6 +331,7 @@ test("retained explicit pin rejects latest policy denial without draft effects",
|
||||
assert.equal(rows().length, 1, "denial does not move the displayed row");
|
||||
await act(async () => oldPin(row));
|
||||
assert.deepEqual(effects, []);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []);
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
|
||||
@@ -349,6 +355,7 @@ for (const returnToOrigin of [false, true]) {
|
||||
edit = oldInsert(row, 1);
|
||||
});
|
||||
assert.deepEqual(effects, []);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []);
|
||||
assert.equal(edit.insertText, "");
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
@@ -592,6 +599,9 @@ test("background membership/search updates leave visible same-name rows and Tab
|
||||
mention.getDraftMentionRefs(edit.insertText)[0].pubkey,
|
||||
selected.pubkey,
|
||||
);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), [
|
||||
selected.pubkey,
|
||||
]);
|
||||
await act(async () => mention.updateMentionQuery("@Scou", 5));
|
||||
await settle();
|
||||
assert.equal(mention.suggestions.length, 3);
|
||||
@@ -719,6 +729,7 @@ for (const condition of ["denied", "missing", "failed", "cold-failed"]) {
|
||||
assert.equal(outcome.suggestion, undefined);
|
||||
}
|
||||
assert.deepEqual(effects, []);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []);
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetMentionSelectionHistory } from "@/features/messages/lib/mentionSelectionHistory";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
@@ -58,6 +59,7 @@ async function resetCommunityState({
|
||||
resetAvatarState: boolean;
|
||||
}): Promise<void> {
|
||||
relayClient.disconnect();
|
||||
resetMentionSelectionHistory();
|
||||
await resetNavigationDeepLinkDrain();
|
||||
resetRateLimitGate();
|
||||
clearAllDrafts();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildMentionCandidates } from "./buildMentionCandidates.ts";
|
||||
import { rankMentionCandidates } from "./mentionRanking.ts";
|
||||
import { getMentionMemberPubkeys } from "./mentionMemberPubkeys.ts";
|
||||
import {
|
||||
getMentionSelectionHistory,
|
||||
rememberMentionSelection,
|
||||
resetMentionSelectionHistory,
|
||||
} from "./mentionSelectionHistory.ts";
|
||||
|
||||
const A = "a".repeat(64),
|
||||
B = "b".repeat(64),
|
||||
C = "c".repeat(64),
|
||||
VIEWER = "f".repeat(64);
|
||||
function input(overrides = {}) {
|
||||
return {
|
||||
@@ -161,3 +168,99 @@ test("relay presence and verified ownership are independent of local stopped sta
|
||||
);
|
||||
assert.equal(stale.presence, "unknown");
|
||||
});
|
||||
const candidate = (pubkey, extra = {}) => ({
|
||||
kind: "identity",
|
||||
pubkey,
|
||||
displayName: "Scout",
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
action: "mention",
|
||||
...extra,
|
||||
});
|
||||
test("collision order is explicit recent, owned, Online > Away > unknown/Offline, then exact key", () => {
|
||||
const rows = [
|
||||
candidate(A, { presence: "online" }),
|
||||
candidate(B, { isOwned: true, presence: "offline" }),
|
||||
candidate(C, { presence: "away" }),
|
||||
];
|
||||
const keys = (items, history = []) =>
|
||||
rankMentionCandidates(items, "", new Set(), history).map(
|
||||
(x) => x.candidate.pubkey,
|
||||
);
|
||||
assert.deepEqual(keys(rows), [B, A, C]);
|
||||
assert.deepEqual(keys(rows, [C]), [C, B, A]);
|
||||
for (const permutation of [
|
||||
rows,
|
||||
[...rows].reverse(),
|
||||
[rows[1], rows[0], rows[2]],
|
||||
[rows[2], rows[0], rows[1]],
|
||||
])
|
||||
assert.deepEqual(keys(permutation), [B, A, C]);
|
||||
assert.deepEqual(
|
||||
keys([
|
||||
candidate(B, { presence: "unknown" }),
|
||||
candidate(A, { presence: "offline" }),
|
||||
]),
|
||||
[A, B],
|
||||
);
|
||||
assert.deepEqual(
|
||||
keys([
|
||||
candidate(B, { action: "unavailable" }),
|
||||
candidate(A, { isMember: false, action: "invite" }),
|
||||
]),
|
||||
[A, B],
|
||||
);
|
||||
});
|
||||
test("unrelated text/kind slots do not participate in conditional-comparator cycles", () => {
|
||||
const rows = [
|
||||
candidate(B),
|
||||
candidate(C, { displayName: "Someone", isAgent: false }),
|
||||
candidate(A),
|
||||
];
|
||||
assert.deepEqual(
|
||||
rankMentionCandidates(rows, "").map((x) => x.candidate.pubkey),
|
||||
[A, C, B],
|
||||
);
|
||||
});
|
||||
test("explicit history is channel/user scoped, bounded and cleared at community reset", () => {
|
||||
resetMentionSelectionHistory();
|
||||
rememberMentionSelection(VIEWER, "room", A);
|
||||
rememberMentionSelection(VIEWER, "room", B);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, "room"), [B, A]);
|
||||
assert.deepEqual(getMentionSelectionHistory(A, "room"), []);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, "other"), []);
|
||||
resetMentionSelectionHistory();
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, "room"), []);
|
||||
});
|
||||
|
||||
test("history normalizes keys and evicts excess entries and scopes", () => {
|
||||
resetMentionSelectionHistory();
|
||||
for (let i = 0; i < 55; i++)
|
||||
rememberMentionSelection(VIEWER, "room", i.toString(16).padStart(64, "0"));
|
||||
assert.equal(getMentionSelectionHistory(VIEWER, "room").length, 50);
|
||||
rememberMentionSelection(VIEWER.toUpperCase(), "room", A.toUpperCase());
|
||||
assert.equal(getMentionSelectionHistory(VIEWER, "room")[0], A);
|
||||
for (let i = 0; i < 100; i++)
|
||||
rememberMentionSelection(VIEWER, `room-${i}`, A);
|
||||
assert.deepEqual(getMentionSelectionHistory(VIEWER, "room"), []);
|
||||
resetMentionSelectionHistory();
|
||||
});
|
||||
|
||||
test("ranking before the visible slice preserves persona and team slots", () => {
|
||||
const rows = [
|
||||
candidate(B, { isMember: false, personaId: "active" }),
|
||||
candidate(undefined, {
|
||||
kind: "persona",
|
||||
isMember: false,
|
||||
personaId: "active",
|
||||
}),
|
||||
candidate(undefined, { kind: "team", isMember: false }),
|
||||
candidate(A, { isMember: false, personaId: "active", isOwned: true }),
|
||||
];
|
||||
const rank = (history = []) =>
|
||||
rankMentionCandidates(rows, "Scout", new Set(["active"]), history)
|
||||
.slice(0, 3)
|
||||
.map((item) => item.candidate);
|
||||
assert.deepEqual(rank(), [rows[3], rows[1], rows[2]]);
|
||||
assert.deepEqual(rank([B]), [rows[0], rows[1], rows[2]]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { isMentionActionable, type MentionAction } from "./mentionPresentation";
|
||||
import {
|
||||
isMentionActionable,
|
||||
type MentionAction,
|
||||
type MentionPresence,
|
||||
} from "./mentionPresentation";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type MentionCandidateForRanking = {
|
||||
displayName: string | null;
|
||||
action?: MentionAction;
|
||||
isOwned?: boolean;
|
||||
presence?: MentionPresence;
|
||||
isAgent: boolean;
|
||||
isActiveAgent?: boolean;
|
||||
isMember: boolean;
|
||||
@@ -116,10 +122,11 @@ export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
candidates: readonly T[],
|
||||
query: string,
|
||||
activePersonaIds: ReadonlySet<string> = new Set(),
|
||||
recentExplicitPubkeys: readonly string[] = [],
|
||||
): RankedMentionCandidate<T>[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return candidates
|
||||
const ranked = candidates
|
||||
.map((candidate, order) => {
|
||||
const pubkeyLower = candidate.pubkey
|
||||
? normalizePubkey(candidate.pubkey)
|
||||
@@ -160,4 +167,43 @@ export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
(a, b) =>
|
||||
a.groupRank - b.groupRank || a.score - b.score || a.order - b.order,
|
||||
);
|
||||
// Reorder only comparable same-name agent slots. A conditional pairwise
|
||||
// comparator against unrelated rows creates cycles (A<B, B<C, C<A).
|
||||
const groups = new Map<string, number[]>();
|
||||
ranked.forEach((item, index) => {
|
||||
if (
|
||||
item.candidate.kind !== "identity" ||
|
||||
!item.candidate.pubkey ||
|
||||
!item.candidate.isAgent ||
|
||||
!isMentionActionable(item.candidate)
|
||||
)
|
||||
return;
|
||||
const group = `${item.groupRank}:${item.score}:${item.label.trim().toLowerCase()}`;
|
||||
groups.set(group, [...(groups.get(group) ?? []), index]);
|
||||
});
|
||||
const recent = new Map(
|
||||
recentExplicitPubkeys.map((key, index) => [normalizePubkey(key), index]),
|
||||
);
|
||||
const presenceRank = (value?: MentionPresence) =>
|
||||
value === "online" ? 0 : value === "away" ? 1 : 2;
|
||||
for (const indexes of groups.values()) {
|
||||
if (indexes.length < 2) continue;
|
||||
const sorted = indexes
|
||||
.map((index) => ranked[index])
|
||||
.sort((a, b) => {
|
||||
const left = a.candidate,
|
||||
right = b.candidate;
|
||||
return (
|
||||
(recent.get(normalizePubkey(left.pubkey ?? "")) ?? Infinity) -
|
||||
(recent.get(normalizePubkey(right.pubkey ?? "")) ?? Infinity) ||
|
||||
Number(right.isOwned === true) - Number(left.isOwned === true) ||
|
||||
presenceRank(left.presence) - presenceRank(right.presence) ||
|
||||
(left.pubkey ?? "").localeCompare(right.pubkey ?? "")
|
||||
);
|
||||
});
|
||||
indexes.forEach((index, i) => {
|
||||
ranked[index] = sorted[i];
|
||||
});
|
||||
}
|
||||
return ranked;
|
||||
}
|
||||
|
||||
44
desktop/src/features/messages/lib/mentionSelectionHistory.ts
Normal file
44
desktop/src/features/messages/lib/mentionSelectionHistory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
// Community lifetime is owned by resetCommunityState. Keys additionally isolate
|
||||
// identities/channels; this is selection intent, never authorization or pins.
|
||||
const history = new Map<string, string[]>();
|
||||
const scopeKey = (viewer: string, channel: string) =>
|
||||
`${normalizePubkey(viewer)}:${channel}`;
|
||||
|
||||
/** Clear selection intent when the community changes. */
|
||||
export function resetMentionSelectionHistory() {
|
||||
history.clear();
|
||||
}
|
||||
|
||||
/** Read recent successful insertions for this viewer and channel only. */
|
||||
export function getMentionSelectionHistory(
|
||||
viewer: string | null,
|
||||
channel: string | null,
|
||||
): readonly string[] {
|
||||
return viewer && channel
|
||||
? (history.get(scopeKey(viewer, channel)) ?? [])
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Record a successful exact-key insertion without creating permission or a pin. */
|
||||
export function rememberMentionSelection(
|
||||
viewer: string | null,
|
||||
channel: string | null,
|
||||
pubkey: string,
|
||||
) {
|
||||
if (!viewer || !channel) return;
|
||||
const scope = scopeKey(viewer, channel);
|
||||
const key = normalizePubkey(pubkey);
|
||||
const previous = history.get(scope) ?? [];
|
||||
history.delete(scope);
|
||||
history.set(
|
||||
scope,
|
||||
[key, ...previous.filter((item) => item !== key)].slice(0, 50),
|
||||
);
|
||||
while (history.size > 100) {
|
||||
const oldest = history.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
history.delete(oldest);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
markMentionCollisions,
|
||||
} from "./mentionPresentation";
|
||||
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
|
||||
import {
|
||||
getMentionSelectionHistory,
|
||||
rememberMentionSelection,
|
||||
} from "./mentionSelectionHistory";
|
||||
import { useMentionEvidence } from "./useMentionEvidence";
|
||||
import * as React from "react";
|
||||
import {
|
||||
@@ -420,6 +424,7 @@ export function useMentions(
|
||||
mentionCandidatesWithTeams,
|
||||
mentionQuery,
|
||||
activePersonaIds,
|
||||
getMentionSelectionHistory(currentPubkey, channelId),
|
||||
)
|
||||
.slice(0, MENTION_SUGGESTION_LIMIT)
|
||||
.map(({ candidate, label }) => ({
|
||||
@@ -438,6 +443,7 @@ export function useMentions(
|
||||
activePersonaIds,
|
||||
agentDirectoriesReady,
|
||||
retryMention,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
mentionCandidatesWithTeams,
|
||||
mentionQuery,
|
||||
@@ -592,6 +598,8 @@ export function useMentions(
|
||||
replaceToOffset: selectionEnd,
|
||||
insertText: "",
|
||||
};
|
||||
if (suggestion.pubkey)
|
||||
rememberMentionSelection(currentPubkey, channelId, suggestion.pubkey);
|
||||
const [boundSuggestion] = selectedMentionLabels(
|
||||
[suggestion],
|
||||
mentionMapRef.current,
|
||||
@@ -666,6 +674,8 @@ export function useMentions(
|
||||
knownAgentPubkeys,
|
||||
query,
|
||||
suggestions,
|
||||
currentPubkey,
|
||||
channelId,
|
||||
],
|
||||
);
|
||||
const registerMentionPubkey = React.useCallback(
|
||||
|
||||
@@ -76,7 +76,12 @@ test("collision distinction, deliberate key choice, and exact publication", asyn
|
||||
const rowIds = await page
|
||||
.locator("[data-mention-suggestion-index]")
|
||||
.evaluateAll((rows) => rows.map((row) => row.getAttribute("data-testid")));
|
||||
expect(rowIds).toEqual([
|
||||
`mention-suggestion-${A}`,
|
||||
`mention-suggestion-${B}`,
|
||||
]);
|
||||
const first = rowIds[1]?.endsWith(A) ? A : B;
|
||||
const second = first === A ? B : A;
|
||||
await input.press("ArrowDown");
|
||||
// Membership/presence changes affect the next request, not the visible order.
|
||||
await page.evaluate(() => {
|
||||
@@ -122,6 +127,17 @@ test("collision distinction, deliberate key choice, and exact publication", asyn
|
||||
),
|
||||
)
|
||||
.toEqual([[first]]);
|
||||
await input.fill("@Scout");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("[data-mention-suggestion-index]")
|
||||
.evaluateAll((rows) =>
|
||||
rows.map((row) => row.getAttribute("data-testid")),
|
||||
),
|
||||
)
|
||||
.toEqual([`mention-suggestion-${first}`, `mention-suggestion-${second}`]);
|
||||
await capture(page, "next-open-ranking");
|
||||
});
|
||||
|
||||
test("Escape discards delayed picker results across navigation", async ({
|
||||
|
||||
Reference in New Issue
Block a user