From 9b211ae4d0b47c5a59628a20dd35014d8aef2c2f Mon Sep 17 00:00:00 2001 From: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 16 Jul 2026 20:17:05 -0700 Subject: [PATCH] fix: prioritize owned agents in mention autocomplete Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> --- .../messages/lib/flushMentionDebounce.ts | 1 + .../messages/lib/mentionRanking.test.mjs | 39 ++++++++++++++++++- .../features/messages/lib/mentionRanking.ts | 25 +++++++++++- .../src/features/messages/lib/useMentions.ts | 2 +- .../mentions/mention_candidates_provider.dart | 6 ++- .../channels/mentions/mention_ranking.dart | 28 +++++++++---- .../mentions/mention_ranking_test.dart | 33 +++++++++++++++- 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.ts b/desktop/src/features/messages/lib/flushMentionDebounce.ts index fc343e4d8..97355e682 100644 --- a/desktop/src/features/messages/lib/flushMentionDebounce.ts +++ b/desktop/src/features/messages/lib/flushMentionDebounce.ts @@ -62,6 +62,7 @@ export function flushMentionDebounce(opts: { opts.candidates, mention.query, opts.activePersonaIds, + opts.currentPubkey, ); if (ranked.length === 0) { diff --git a/desktop/src/features/messages/lib/mentionRanking.test.mjs b/desktop/src/features/messages/lib/mentionRanking.test.mjs index 2e74bba52..3061dfe7c 100644 --- a/desktop/src/features/messages/lib/mentionRanking.test.mjs +++ b/desktop/src/features/messages/lib/mentionRanking.test.mjs @@ -21,12 +21,49 @@ function rankedPubkeys( candidates, query = "brain", activePersonaIds = new Set(), + currentPubkey, ) { - return rankMentionCandidates(candidates, query, activePersonaIds).map( + return rankMentionCandidates( + candidates, + query, + activePersonaIds, + currentPubkey, + ).map( (item) => item.candidate.pubkey ?? `persona:${item.candidate.personaId}`, ); } +test("rankMentionCandidates: current user's agents rank before all other candidates", () => { + const currentPubkey = "a".repeat(64); + const ownedAgent = candidate({ + displayName: "Brain", + isAgent: true, + ownerPubkey: currentPubkey.toUpperCase(), + pubkey: "7".repeat(64), + }); + const channelMember = candidate({ + displayName: "Brain", + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + const remoteAgent = candidate({ + displayName: "Brain", + isAgent: true, + ownerPubkey: "b".repeat(64), + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.deepEqual( + rankedPubkeys( + [channelMember, remoteAgent, ownedAgent], + "brain", + new Set(), + currentPubkey, + ), + ["7".repeat(64), CHANNEL_BRAIN_PUBKEY, OTHER_BRAIN_PUBKEY], + ); +}); + test("rankMentionCandidates: channel members outrank runnable personas, people, and other agents", () => { const persona = candidate({ kind: "persona", diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de..6560197db 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -9,11 +9,13 @@ export type MentionCandidateForRanking = { personaName?: string | null; pubkey?: string; secondaryLabel?: string | null; + ownerPubkey?: string | null; }; export type RankedMentionCandidate = { candidate: T; groupRank: number; + isOwnedByCurrentUser: boolean; label: string; order: number; score: number; @@ -55,8 +57,12 @@ export function rankMentionCandidates( candidates: readonly T[], query: string, activePersonaIds: ReadonlySet = new Set(), + currentPubkey?: string | null, ): RankedMentionCandidate[] { const lowerQuery = query.toLowerCase(); + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; return candidates .map((candidate, order) => { @@ -91,12 +97,27 @@ export function rankMentionCandidates( : null : null; const score = labelScore !== null ? labelScore : pubkeyScore; + const isOwnedByCurrentUser = + candidate.isAgent && + normalizedCurrentPubkey !== null && + candidate.ownerPubkey != null && + normalizePubkey(candidate.ownerPubkey) === normalizedCurrentPubkey; - return { candidate, groupRank, label, order, score }; + return { + candidate, + groupRank, + isOwnedByCurrentUser, + label, + order, + score, + }; }) .filter((item): item is RankedMentionCandidate => item.score !== null) .sort( (a, b) => - a.groupRank - b.groupRank || a.score - b.score || a.order - b.order, + Number(b.isOwnedByCurrentUser) - Number(a.isOwnedByCurrentUser) || + a.groupRank - b.groupRank || + a.score - b.score || + a.order - b.order, ); } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 07fe7debe..e3957f8e7 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -519,7 +519,6 @@ export function useMentions( () => searchableNames.map((n) => n.toLowerCase()), [searchableNames], ); - // --- Debounce infrastructure for updateMentionQuery --- const debounceTimerRef = React.useRef | null>( null, @@ -552,6 +551,7 @@ export function useMentions( mentionCandidatesWithTeams, mentionQuery, activePersonaIds, + currentPubkey, ) .slice( 0, diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 8736c7ff1..c138d9ec5 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -133,5 +133,9 @@ final mentionCandidatesProvider = Provider.family currentPubkey: currentPubkey, ); - return rankMentionCandidates(candidates, args.query); + return rankMentionCandidates( + candidates, + args.query, + currentPubkey: currentPubkey, + ); }); diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart index 0d0875f0e..a656e0574 100644 --- a/mobile/lib/features/channels/mentions/mention_ranking.dart +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -60,11 +60,13 @@ int? _scoreLabel(String label, String lowerQuery) { /// stable original order. List rankMentionCandidates( List candidates, - String query, -) { + String query, { + String? currentPubkey, +}) { final lowerQuery = query.toLowerCase(); + final currentLower = currentPubkey?.toLowerCase(); - final ranked = <(MentionCandidate, int, int, int)>[]; + final ranked = <(MentionCandidate, bool, int, int, int)>[]; for (var order = 0; order < candidates.length; order++) { final candidate = candidates[order]; @@ -89,15 +91,27 @@ List rankMentionCandidates( } if (score == null) continue; - ranked.add((candidate, _groupRank(candidate), score, order)); + final isOwnedByCurrentUser = + candidate.isAgent && + currentLower != null && + candidate.ownerPubkey?.toLowerCase() == currentLower; + ranked.add(( + candidate, + isOwnedByCurrentUser, + _groupRank(candidate), + score, + order, + )); } ranked.sort((a, b) { - final group = a.$2.compareTo(b.$2); + final owned = (b.$2 ? 1 : 0).compareTo(a.$2 ? 1 : 0); + if (owned != 0) return owned; + final group = a.$3.compareTo(b.$3); if (group != 0) return group; - final score = a.$3.compareTo(b.$3); + final score = a.$4.compareTo(b.$4); if (score != 0) return score; - return a.$4.compareTo(b.$4); + return a.$5.compareTo(b.$5); }); return [for (final item in ranked) item.$1]; diff --git a/mobile/test/features/channels/mentions/mention_ranking_test.dart b/mobile/test/features/channels/mentions/mention_ranking_test.dart index 1e53f611c..7868e56d4 100644 --- a/mobile/test/features/channels/mentions/mention_ranking_test.dart +++ b/mobile/test/features/channels/mentions/mention_ranking_test.dart @@ -12,6 +12,7 @@ MentionCandidate candidate({ String? secondaryLabel, bool isAgent = false, bool isMember = false, + String? ownerPubkey, String? pubkey, }) { return MentionCandidate( @@ -20,20 +21,50 @@ MentionCandidate candidate({ secondaryLabel: secondaryLabel, isAgent: isAgent, isMember: isMember, + ownerPubkey: ownerPubkey, ); } List rankedPubkeys( List candidates, [ String query = 'brain', + String? currentPubkey, ]) { return [ - for (final ranked in rankMentionCandidates(candidates, query)) + for (final ranked in rankMentionCandidates( + candidates, + query, + currentPubkey: currentPubkey, + )) ranked.pubkey, ]; } void main() { + test("current user's agents rank before all other candidates", () { + final currentPubkey = 'a' * 64; + final ownedAgent = candidate( + isAgent: true, + ownerPubkey: currentPubkey.toUpperCase(), + pubkey: '7' * 64, + ); + final channelMember = candidate(isMember: true, pubkey: channelBrainPubkey); + final remoteAgent = candidate( + isAgent: true, + ownerPubkey: 'b' * 64, + pubkey: otherBrainPubkey, + ); + + expect( + rankedPubkeys( + [channelMember, remoteAgent, ownedAgent], + 'brain', + currentPubkey, + ), + ['7' * 64, channelBrainPubkey, otherBrainPubkey], + ); + }); + test('channel members outrank people and other agents', () { final remoteAgent = candidate(isAgent: true, pubkey: otherBrainPubkey); final person = candidate(pubkey: '6' * 64);