Skip to content
Draft
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
11 changes: 11 additions & 0 deletions desktop/src/features/channels/mentionAdmissionJourney.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -246,6 +250,7 @@ async function setup(overrides = {}) {
}
afterEach(async () => {
if (root) await act(async () => root.unmount());
resetMentionSelectionHistory();
client?.clear();
document.body.replaceChildren();
});
Expand Down Expand Up @@ -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, []);
});

Expand All @@ -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, []);
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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, []);
});
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,6 +59,7 @@ async function resetCommunityState({
resetAvatarState: boolean;
}): Promise<void> {
relayClient.disconnect();
resetMentionSelectionHistory();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
Expand Down
103 changes: 103 additions & 0 deletions desktop/src/features/messages/lib/mentionPresentation.test.mjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -160,3 +167,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]]);
});
50 changes: 48 additions & 2 deletions desktop/src/features/messages/lib/mentionRanking.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 changes: 44 additions & 0 deletions desktop/src/features/messages/lib/mentionSelectionHistory.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import {
isMentionActionable,
markMentionCollisions,
} from "./mentionPresentation";
import {
getMentionSelectionHistory,
rememberMentionSelection,
} from "./mentionSelectionHistory";
import { useMentionEvidence } from "./useMentionEvidence";
import * as React from "react";
import {
Expand Down Expand Up @@ -408,6 +412,7 @@ export function useMentions(
mentionCandidatesWithTeams,
mentionQuery,
activePersonaIds,
getMentionSelectionHistory(currentPubkey, channelId),
)
.slice(0, MENTION_SUGGESTION_LIMIT)
.map(({ candidate, label }) => ({
Expand All @@ -426,6 +431,7 @@ export function useMentions(
activePersonaIds,
agentDirectoriesReady,
retryMention,
channelId,
currentPubkey,
mentionCandidatesWithTeams,
mentionQuery,
Expand Down Expand Up @@ -572,6 +578,8 @@ export function useMentions(
replaceToOffset: selectionEnd,
insertText: "",
};
if (suggestion.pubkey)
rememberMentionSelection(currentPubkey, channelId, suggestion.pubkey);
const [boundSuggestion] = selectedMentionLabels(
[suggestion],
mentionMapRef.current,
Expand Down Expand Up @@ -646,6 +654,8 @@ export function useMentions(
knownAgentPubkeys,
query,
suggestions,
currentPubkey,
channelId,
],
);
const registerMentionPubkey = React.useCallback(
Expand Down
16 changes: 16 additions & 0 deletions desktop/tests/e2e/mention-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 ({
Expand Down
Loading