diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index 9da1edcaf9..b9a6764643 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -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, []); }); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a3b17f5ea3..6069102aac 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -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 { relayClient.disconnect(); + resetMentionSelectionHistory(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); diff --git a/desktop/src/features/messages/lib/mentionPresentation.test.mjs b/desktop/src/features/messages/lib/mentionPresentation.test.mjs index 864cb2b551..2631a75b42 100644 --- a/desktop/src/features/messages/lib/mentionPresentation.test.mjs +++ b/desktop/src/features/messages/lib/mentionPresentation.test.mjs @@ -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]]); +}); diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index f346494089..cfb6572a0c 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -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( candidates: readonly T[], query: string, activePersonaIds: ReadonlySet = new Set(), + recentExplicitPubkeys: readonly string[] = [], ): RankedMentionCandidate[] { 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( (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(); + 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; } diff --git a/desktop/src/features/messages/lib/mentionSelectionHistory.ts b/desktop/src/features/messages/lib/mentionSelectionHistory.ts new file mode 100644 index 0000000000..d3ab2265e9 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionSelectionHistory.ts @@ -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(); +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); + } +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index dd9a731348..0180b9ab46 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -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( diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index b7dab66c9d..0479bf80ae 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -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 ({