diff --git a/desktop/src/features/channels/membershipMentionJourney.test.mjs b/desktop/src/features/channels/membershipMentionJourney.test.mjs index cb6a314c623..c600e729712 100644 --- a/desktop/src/features/channels/membershipMentionJourney.test.mjs +++ b/desktop/src/features/channels/membershipMentionJourney.test.mjs @@ -280,10 +280,7 @@ for (const [owner, role] of [ ]) { test(`create/add refreshes discovery for the next open, not the displayed rows (${owner === VIEWER ? "owned" : "nonowned"}, ${role})`, async () => { await setup({ owner, role }); - assert.equal( - rows().filter((row) => row.isAgent && !row.notInChannel).length, - 0, - ); + assert.equal(rows().filter((row) => row.action === "mention").length, 0); assert.equal( state.directoryCalls, 1, @@ -300,7 +297,7 @@ for (const [owner, role] of [ "accepted add refreshes even while the roster lags", ); assert.equal( - rows().filter((row) => row.isAgent && !row.notInChannel).length, + rows().filter((row) => row.action === "mention").length, 0, "acceptance does not fabricate membership (owned preparation may offer Invite)", ); @@ -348,6 +345,37 @@ for (const [owner, role] of [ }); } +for (const condition of ["denied", "missing", "failed"]) { + test(`membership alone never admits a ${condition} agent`, async () => { + await setup({ owner: OTHER }); + state.visible = true; + state.directoryVisible = true; + state.policy = condition === "denied" ? "owner-only" : "anyone"; + state.missingDirectory = condition === "missing"; + state.failDirectory = condition === "failed"; + await act(async () => + mutations.add.mutateAsync({ + pubkeys: [AGENT], + role: state.role, + channelId: CHANNEL, + }), + ); + await settle(); + assert.equal(mention.memberPubkeys.has(AGENT), true); + await act(async () => mention.openMentionPicker(1)); + assert.equal( + rows().length, + 1, + "an already visible member explains its disabled action", + ); + assert.equal( + rows()[0].action, + condition === "missing" ? "checking" : "unavailable", + ); + assert.equal(rows()[0].notInChannel, false); + }); +} + test("an all-rejected add does not trigger a directory rebuild", async () => { await setup({ addResult: { added: [], errors: [{ pubkey: AGENT, error: "denied" }] }, @@ -384,10 +412,7 @@ test("a cancelled late roster cannot trigger discovery or resurrect its member", await settle(); assert.equal(state.directoryCalls, beforeCalls); assert.equal(mention.memberPubkeys.has(AGENT), false); - assert.equal( - rows().filter((row) => row.isAgent && !row.notInChannel).length, - 0, - ); + assert.equal(rows().filter((row) => row.action === "mention").length, 0); }); for (const change of [ @@ -427,6 +452,11 @@ for (const change of [ "old actionable row must not establish intent", ); assert.deepEqual(mention.knownNames, []); + assert.equal( + mention.isAgentPubkey(AGENT), + true, + "directory removal never turns a known agent into a human", + ); }); } @@ -713,12 +743,12 @@ test("background membership/search updates leave visible same-name rows and Tab client.invalidateQueries({ queryKey: ["user-search"] }), ); await settle(); - assert.equal(mention.suggestions, displayed); + assert.deepEqual(mention.suggestions, displayed); let outcome, edit; await act(async () => { outcome = mention.handleMentionKeyDown(keyboard("Tab")); }); - assert.equal(outcome.suggestion, selected); + assert.deepEqual(outcome.suggestion, selected); await act(async () => { edit = mention.insertMention(outcome.suggestion, 6); }); @@ -764,7 +794,7 @@ test("text changes load a new request; superseded and closed responses cannot in releaseOld({ users: [person(VIEWER, "Old")], next_cursor: null }), ); await settle(); - assert.equal(mention.suggestions, displayed); + assert.deepEqual(mention.suggestions, displayed); await act(async () => mention.updateMentionQuery("@closed", 7)); await settle(); await act(async () => mention.cancelMentionAutocomplete()); @@ -814,3 +844,84 @@ test("Space completes an exact name but remains literal for partial and same-nam mention.suggestions[0].pubkey, ); }); + +for (const condition of ["denied", "missing", "failed"]) { + test(`disabled ${condition} member rejects pointer, keyboard and new pin intent`, async () => { + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + policy: condition === "denied" ? "owner-only" : "anyone", + missingDirectory: condition === "missing", + failDirectory: condition === "failed", + }); + const row = rows()[0]; + assert.equal(mention.canSelectMention(row), false); + await act(async () => { + picker.selectMentionSuggestion(row); + picker.toggleAlwaysAddressAgent(row); + mention.handleMentionKeyDown(keyboard("ArrowDown")); + }); + for (const key of ["Tab", "Enter", " "]) { + let outcome; + await act(async () => { + outcome = mention.handleMentionKeyDown(keyboard(key)); + }); + assert.equal(outcome.suggestion, undefined); + } + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); + }); +} + +test("checking resolves and retry refreshes without moving the selected identity", async () => { + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + missingDirectory: true, + }); + const identity = rows()[0].pubkey; + assert.equal(rows()[0].action, "checking"); + state.missingDirectory = false; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows()[0].pubkey, identity); + assert.equal(mention.mentionSelectedIndex, 0); + assert.equal(rows()[0].action, "mention"); + assert.equal(mention.canSelectMention(rows()[0]), true); + state.failDirectory = true; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + assert.equal(mention.canSelectMention(rows()[0]), false); + state.failDirectory = false; + await act(async () => rows()[0].onRetry()); + await settle(); + assert.equal(rows()[0].pubkey, identity); + assert.equal(rows()[0].action, "mention"); +}); + +test("verification expiry never installs an unfinished people search", async () => { + await setup(); + let release; + state.pendingSearch = { + slow: new Promise((resolve) => { + release = resolve; + }), + }; + await act(async () => mention.updateMentionQuery("@slow", 5)); + await act(async () => new Promise((resolve) => setTimeout(resolve, 5100))); + assert.equal(mention.isMentionLoading, true); + assert.deepEqual(mention.suggestions, []); + await act(async () => + release({ users: [person(OTHER, "Slow")], next_cursor: null }), + ); + await settle(); + assert.equal(mention.isMentionLoading, false); + assert.equal(mention.suggestions[0].pubkey, OTHER); +}); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index a876cd704b9..0fc0540f805 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -1,9 +1,5 @@ +import { relayAgentIsSharedWithUser } from "@/features/agents/lib/agentAutocompleteEligibility"; import { markMentionCollisions } from "./mentionPresentation"; -import { - coalesceAgentAutocompleteCandidates, - coalesceAutocompleteCandidatesByKey, - shouldHideAgentFromMentions, -} from "@/features/agents/lib/agentAutocompleteEligibility"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { AgentPersona, @@ -16,14 +12,15 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { formatSearchUserDisplayName, formatSearchUserSecondaryLabel, - globalSearchIdentityKey, type MentionCandidate, - mentionCandidateLabel, } from "./mentionCandidates"; /** Directories and rosters the mention picker merges into one candidate list. */ export type BuildMentionCandidatesInput = { activeAgentPubkeys: ReadonlySet; + knownAgentPubkeys?: ReadonlySet; + verificationFailed?: boolean; + presenceFresh?: boolean; activePersonaById: ReadonlyMap; /** Already narrowed to `isActive` personas. */ activePersonas: readonly AgentPersona[]; @@ -56,6 +53,9 @@ export type BuildMentionCandidatesInput = { */ export function buildMentionCandidates({ activeAgentPubkeys, + knownAgentPubkeys = new Set(), + verificationFailed = false, + presenceFresh = true, activePersonaById, activePersonas, canSearchGlobalUsers, @@ -83,19 +83,6 @@ export function buildMentionCandidates({ if (isArchived(pubkey)) { return; } - if ( - shouldHideAgentFromMentions({ - isAgent: candidate.isAgent === true, - pubkey, - mentionableAgentPubkeys, - directoryReady: - candidate.isManagedAgent === true - ? managedAgentDirectoryReady - : relayAgentDirectoryReady, - }) - ) { - return; - } const current = candidatesByPubkey.get(pubkey); if (!current) { candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); @@ -174,9 +161,9 @@ export function buildMentionCandidates({ // is filtered by access policy, so its channel ids can legitimately omit // a room where this identity is already a member. isMember: - memberPubkeys.has(pubkey) || - (mentionChannelId !== null && - agent.channelIds.includes(mentionChannelId)), + members !== undefined + ? members.some((member) => normalizePubkey(member.pubkey) === pubkey) + : memberPubkeys.has(pubkey), personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), @@ -236,17 +223,90 @@ export function buildMentionCandidates({ isAgent: true, })) .filter((candidate) => candidate.displayName.trim().length > 0); - return markMentionCollisions( - coalesceAgentAutocompleteCandidates( - coalesceAutocompleteCandidatesByKey( - [...candidatesByPubkey.values(), ...personaCandidates], - globalSearchIdentityKey, - ), - { + // Classify the exact-key union BEFORE admission. A known agent returned by + // people search must never bypass policy as a human. + const relayByKey = new Map( + (relayAgents ?? []).map((a) => [normalizePubkey(a.pubkey), a]), + ); + const managedByKey = new Map( + (managedAgents ?? []).map((a) => [normalizePubkey(a.pubkey), a]), + ); + const roster = + members === undefined + ? memberPubkeys + : new Set(members.map((m) => normalizePubkey(m.pubkey))); + const union = [...candidatesByPubkey.values()].map((candidate) => { + const key = candidate.pubkey ?? ""; + const relay = relayByKey.get(key); + const managed = managedByKey.get(key); + const isAgent = + candidate.isAgent || !!relay || !!managed || knownAgentPubkeys.has(key); + // Profiles/search may classify an agent, but do not verify ownership. + const ownerPubkey = relay?.ownerPubkey ?? (managed ? currentPubkey : null); + const ready = managed + ? managedAgentDirectoryReady + : relayAgentDirectoryReady; + const hasEvidence = !!managed || !!relay; + const isMember = roster.has(key); + const memberPolicyAllows = + relay && + isMember && + mentionChannelId && + relayAgentIsSharedWithUser( + { ...relay, channelIds: [mentionChannelId] }, + new Set([mentionChannelId]), currentPubkey, - getLabel: mentionCandidateLabel, - preferredPubkeys: memberPubkeys, - }, - ), + ); + const allowed = + ready && + hasEvidence && + (mentionableAgentPubkeys.has(key) || memberPolicyAllows); + const action = + !isAgent || allowed + ? isMember + ? "mention" + : "invite" + : ready && hasEvidence + ? "unavailable" + : verificationFailed + ? "unavailable" + : "checking"; + return { + ...candidate, + isAgent, + isMember, + ownerPubkey, + isOwned: + !!ownerPubkey && + !!currentPubkey && + normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), + action, + unavailableReason: + action === "unavailable" + ? ready && hasEvidence + ? "This agent does not permit you to mention it here." + : "Could not verify access. Retry to check again." + : action === "checking" + ? "Checking access…" + : undefined, + presence: + isAgent && relay && presenceFresh && relayAgentDirectoryReady + ? relay.status + : "unknown", + localLifecycle: managed?.status, + localError: Boolean(managed?.lastError), + } satisfies MentionCandidate; + }); + // No new disclosure: unverified/denied directory-only nonmembers remain + // hidden. Current roster identities and local managed identities are already + // visible and can explain an unavailable action without granting one. + const marked = markMentionCollisions([...union, ...personaCandidates]); + return marked.filter( + (candidate) => + !candidate.isAgent || + candidate.kind !== "identity" || + candidate.isMember || + candidate.action === "invite" || + (candidate.isManagedAgent && candidate.action === "checking"), ); } diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 33a12d6ea1b..097a7c5395f 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -1,4 +1,4 @@ -import type { MentionAction } from "./mentionPresentation"; +import type { MentionAction, MentionPresence } from "./mentionPresentation"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import type { AgentPersona, @@ -34,8 +34,6 @@ export type TeamMentionMember = { }; export type MentionCandidate = { - action?: MentionAction; - hasNameCollision?: boolean; kind: "identity" | "persona" | "team"; pubkey?: string; personaId?: string; @@ -52,6 +50,13 @@ export type MentionCandidate = { isActiveAgent?: boolean; isManagedAgent?: boolean; isGlobalSearchResult?: boolean; + action?: MentionAction; + unavailableReason?: string; + presence?: MentionPresence; + localLifecycle?: string; + localError?: boolean; + isOwned?: boolean; + hasNameCollision?: boolean; }; export function mentionCandidateLabel(candidate: MentionCandidate) { diff --git a/desktop/src/features/messages/lib/mentionMemberPubkeys.ts b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts index ae1a3e73816..63682339b05 100644 --- a/desktop/src/features/messages/lib/mentionMemberPubkeys.ts +++ b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts @@ -11,6 +11,7 @@ export function getMentionMemberPubkeys( const pubkeys = new Set( members ? channelMemberPubkeySet(members) : undefined, ); + if (members !== undefined) return pubkeys; const activeChannel = channels?.find((channel) => channel.id === channelId); for (const pubkey of activeChannel?.memberPubkeys ?? []) { pubkeys.add(normalizePubkey(pubkey)); diff --git a/desktop/src/features/messages/lib/mentionPresentation.test.mjs b/desktop/src/features/messages/lib/mentionPresentation.test.mjs new file mode 100644 index 00000000000..434d4868a77 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionPresentation.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildMentionCandidates } from "./buildMentionCandidates.ts"; +import { getMentionMemberPubkeys } from "./mentionMemberPubkeys.ts"; + +const A = "a".repeat(64), + B = "b".repeat(64), + VIEWER = "f".repeat(64); +function input(overrides = {}) { + return { + activeAgentPubkeys: new Set(), + activePersonaById: new Map(), + activePersonas: [], + canSearchGlobalUsers: true, + currentPubkey: VIEWER, + isArchived: () => false, + managedAgentDirectoryReady: true, + managedAgentNamesByPubkey: new Map(), + managedAgentPersonaIds: new Set(), + managedAgentPersonaIdsByPubkey: new Map(), + managedAgents: [], + memberPubkeys: new Set(), + members: [], + mentionChannelId: "room", + mentionableAgentPubkeys: new Set(), + personaNameByPubkey: new Map(), + profiles: {}, + relayAgentDirectoryReady: true, + relayAgentNamesByPubkey: new Map(), + relayAgents: [], + userSearchResults: [], + ...overrides, + }; +} +const relay = (pubkey = A, extra = {}) => ({ + pubkey, + name: "Scout", + ownerPubkey: VIEWER, + status: "online", + channelIds: ["room"], + respondTo: "anyone", + respondToAllowlist: [], + ...extra, +}); +const member = (pubkey = A, extra = {}) => ({ + pubkey, + displayName: "Scout", + isAgent: true, + role: "bot", + ...extra, +}); + +for (const role of ["member", "bot"]) + for (const owned of [true, false]) { + test(`authoritative ${role} roster admits allowed ${owned ? "owned" : "nonowned"} exact agent despite lagging directory membership`, () => { + const [row] = buildMentionCandidates( + input({ + members: [member(A, { role })], + relayAgents: [ + relay(A, { channelIds: [], ownerPubkey: owned ? VIEWER : B }), + ], + }), + ); + assert.equal(row.action, "mention"); + assert.equal(row.isOwned, owned); + assert.equal(row.isMember, true); + }); + } +for (const ready of [false, true]) + for (const failed of [false, true]) { + test(`missing directory evidence is not explicit policy denial (${ready}/${failed})`, () => { + const [row] = buildMentionCandidates( + input({ + members: [member()], + relayAgentDirectoryReady: ready, + verificationFailed: failed, + }), + ); + assert.equal(row.action, failed ? "unavailable" : "checking"); + assert.equal(row.ownerPubkey, null); + assert.doesNotMatch(row.unavailableReason, /does not permit/); + }); + } +test("denied member explains policy, denied nonmember omitted, people-search flags cannot bypass exact-key agent classification", () => { + const data = input({ + relayAgents: [relay(A, { respondTo: "owner-only", ownerPubkey: B })], + userSearchResults: [ + { pubkey: A, displayName: "Human disguise", isAgent: false }, + ], + }); + assert.deepEqual(buildMentionCandidates(data), []); + const [row] = buildMentionCandidates({ + ...data, + members: [member(A, { isAgent: false, role: "member" })], + }); + assert.equal(row.action, "unavailable"); + assert.equal(row.isAgent, true); + assert.match(row.unavailableReason, /does not permit/); +}); +test("known removed directory agent cannot reappear as a human; archive overrides every source", () => { + const data = input({ + knownAgentPubkeys: new Set([A]), + members: [member(A, { isAgent: false, role: "member" })], + }); + assert.equal(buildMentionCandidates(data)[0].action, "checking"); + assert.deepEqual( + buildMentionCandidates({ ...data, isArchived: () => true }), + [], + ); +}); +test("fresh roster removal beats stale directory and channel membership; owned nonmember is Invite, not Mention", () => { + const membership = getMentionMemberPubkeys( + "room", + [{ id: "room", memberPubkeys: [A] }], + [], + ); + assert.equal(membership.has(A), false); + const [row] = buildMentionCandidates( + input({ + relayAgents: [relay()], + mentionableAgentPubkeys: new Set([A]), + memberPubkeys: new Set([A]), + }), + ); + assert.equal(row.isMember, false); + assert.equal(row.action, "invite"); +}); +test("union keeps same-named people and marks collisions before the cap", () => { + const rows = buildMentionCandidates( + input({ + userSearchResults: [A, B].map((pubkey) => ({ + pubkey, + displayName: "Sam", + isAgent: false, + })), + }), + ); + assert.equal(rows.length, 2); + assert.ok(rows.every((row) => row.hasNameCollision)); +}); +test("relay presence and verified ownership are independent of local stopped state and unverified profile owner", () => { + const [row] = buildMentionCandidates( + input({ + members: [member()], + managedAgents: [{ pubkey: A, name: "Scout", status: "stopped" }], + relayAgents: [relay()], + mentionableAgentPubkeys: new Set([A]), + profiles: { [A]: { ownerPubkey: B } }, + }), + ); + assert.equal(row.ownerPubkey, VIEWER); + assert.equal(row.presence, "online"); + assert.equal(row.localLifecycle, "stopped"); + const [stale] = buildMentionCandidates( + input({ + members: [member()], + relayAgents: [relay()], + presenceFresh: false, + }), + ); + assert.equal(stale.presence, "unknown"); +}); diff --git a/desktop/src/features/messages/lib/mentionPresentation.ts b/desktop/src/features/messages/lib/mentionPresentation.ts index 4315b4175b5..e2151e54942 100644 --- a/desktop/src/features/messages/lib/mentionPresentation.ts +++ b/desktop/src/features/messages/lib/mentionPresentation.ts @@ -1,6 +1,8 @@ import type { MentionCandidate } from "./mentionCandidates"; + /** Presentation only. Publication still performs fresh authorization. */ export type MentionAction = "mention" | "invite" | "checking" | "unavailable"; +export type MentionPresence = "online" | "away" | "offline" | "unknown"; export function isMentionActionable(candidate: { action?: MentionAction }) { return candidate.action !== "checking" && candidate.action !== "unavailable"; diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 3df5bba0b0a..f346494089e 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -1,7 +1,9 @@ +import { isMentionActionable, type MentionAction } from "./mentionPresentation"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; + action?: MentionAction; isAgent: boolean; isActiveAgent?: boolean; isMember: boolean; @@ -24,6 +26,7 @@ function getMentionCandidateGroupRank( candidate: MentionCandidateForRanking, activePersonaIds: ReadonlySet, ) { + if (!isMentionActionable(candidate)) return 4; if (candidate.isMember) return 0; const isRunnablePersona = @@ -65,7 +68,12 @@ export function pickDefaultAgentCandidate( ); return ( candidates - .filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey)) + .filter( + (candidate) => + candidate.isAgent && + Boolean(candidate.pubkey) && + isMentionActionable(candidate), + ) .sort((left, right) => { const leftRecentRank = left.pubkey ? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey)) diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index 06f6428009d..59074871a10 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -1,3 +1,4 @@ +import type { MentionAction, MentionPresence } from "./mentionPresentation"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { formatOwnerLabel } from "@/features/profile/lib/identity"; @@ -19,6 +20,11 @@ export type MentionSuggestionCandidate = { isMember: boolean; role?: ChannelRole | null; ownerPubkey?: string | null; + action?: MentionAction; + unavailableReason?: string; + presence?: MentionPresence; + localLifecycle?: string; + localError?: boolean; hasNameCollision?: boolean; }; @@ -45,6 +51,11 @@ export function mapMentionCandidateToSuggestion(opts: { : null; return { + action: candidate.action, + unavailableReason: candidate.unavailableReason, + presence: candidate.presence, + localLifecycle: candidate.localLifecycle, + localError: candidate.localError, hasNameCollision: candidate.hasNameCollision, pubkey: candidate.pubkey, personaId: candidate.personaId ?? undefined, diff --git a/desktop/src/features/messages/lib/useMentionEvidence.ts b/desktop/src/features/messages/lib/useMentionEvidence.ts new file mode 100644 index 00000000000..0dc81a85bff --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionEvidence.ts @@ -0,0 +1,55 @@ +import * as React from "react"; + +/** A bounded verification window, not a directory polling loop. */ +export function useMentionEvidence({ + scope, + request, + agentKeys, + directoryUpdatedAt, + directoryError, + retry, +}: { + scope: string; + request: object | null; + agentKeys: ReadonlySet; + directoryUpdatedAt: number; + directoryError: boolean; + retry: () => void; +}) { + const known = React.useRef({ scope, keys: new Set() }); + if (known.current.scope !== scope) known.current = { scope, keys: new Set() }; + for (const key of agentKeys) known.current.keys.add(key); + const [attempt, setAttempt] = React.useState(0); + const [expired, setExpired] = React.useState<{ + request: object; + attempt: number; + } | null>(null); + const [now, setNow] = React.useState(Date.now); + React.useEffect(() => { + if (!request || !scope) return; + const timer = setTimeout(() => setExpired({ request, attempt }), 5000); + return () => clearTimeout(timer); + }, [scope, request, attempt]); + React.useEffect(() => { + setNow(Date.now()); + const delay = directoryUpdatedAt + 180_000 - Date.now(); + if (delay <= 0) return; + const timer = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(timer); + }, [directoryUpdatedAt]); + const retryVerification = React.useCallback(() => { + setExpired(null); + setAttempt((value) => value + 1); + retry(); + }, [retry]); + return { + knownAgentPubkeys: known.current.keys, + verificationFailed: + directoryError || + (!!request && + expired?.request === request && + expired.attempt === attempt), + presenceFresh: directoryUpdatedAt > 0 && now - directoryUpdatedAt < 180_000, + retryVerification, + }; +} diff --git a/desktop/src/features/messages/lib/useMentionQuery.ts b/desktop/src/features/messages/lib/useMentionQuery.ts index 5760a871885..f9f126fb4d6 100644 --- a/desktop/src/features/messages/lib/useMentionQuery.ts +++ b/desktop/src/features/messages/lib/useMentionQuery.ts @@ -101,6 +101,9 @@ export function useMentionQuery( return { request: request?.scope === scope ? request : null, cancel, + refresh: React.useCallback(() => { + if (current.current) publish({ ...current.current }); + }, [publish]), update, open, read, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index de0d8f4f2ab..e260a4d2090 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -2,6 +2,7 @@ import { isMentionActionable, markMentionCollisions, } from "./mentionPresentation"; +import { useMentionEvidence } from "./useMentionEvidence"; import * as React from "react"; import { useManagedAgentsQuery, @@ -222,10 +223,6 @@ export function useMentions( } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); - const knownAgentPubkeys = React.useMemo( - () => new Set([...mentionableAgentPubkeys, ...managedAgentPubkeys]), - [managedAgentPubkeys, mentionableAgentPubkeys], - ); const activePersonas = React.useMemo( () => (personasQuery.data ?? []).filter((persona) => persona.isActive), [personasQuery.data], @@ -252,10 +249,40 @@ export function useMentions( }), [managedAgentPubkeys, members, profiles, relayAgentsQuery.data], ); + const retryDirectory = React.useCallback(() => { + void relayAgentsQuery.refetch(); + void managedAgentsQuery.refetch(); + void membersQuery.refetch(); + }, [ + relayAgentsQuery.refetch, + managedAgentsQuery.refetch, + membersQuery.refetch, + ]); + const { + knownAgentPubkeys, + verificationFailed, + presenceFresh, + retryVerification, + } = useMentionEvidence({ + scope: `${currentPubkey}:${channelId}`, + request: query.request, + agentKeys: new Set([ + ...agentIdentityPubkeys, + ...userSearchResults + .filter((user) => user.isAgent) + .map((user) => normalizePubkey(user.pubkey)), + ]), + directoryUpdatedAt: relayAgentsQuery.dataUpdatedAt, + directoryError: !!relayAgentsQuery.error || !!managedAgentsQuery.error, + retry: retryDirectory, + }); const mentionCandidates = React.useMemo( () => buildMentionCandidates({ activeAgentPubkeys, + knownAgentPubkeys, + verificationFailed, + presenceFresh, activePersonaById, activePersonas, canSearchGlobalUsers, @@ -279,6 +306,9 @@ export function useMentions( }), [ activePersonaById, + knownAgentPubkeys, + verificationFailed, + presenceFresh, activeAgentPubkeys, activePersonas, userSearchResults, @@ -359,6 +389,10 @@ export function useMentions( [searchableNames], ); searchableNamesLowerRef.current = searchableNamesLower; + const retryMention = React.useCallback(() => { + retryVerification(); + query.refresh(); + }, [retryVerification, query.refresh]); const matchingSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; @@ -369,8 +403,8 @@ export function useMentions( activePersonaIds, ) .slice(0, MENTION_SUGGESTION_LIMIT) - .map(({ candidate, label }) => - mapMentionCandidateToSuggestion({ + .map(({ candidate, label }) => ({ + ...mapMentionCandidateToSuggestion({ agentProvenanceReady: agentDirectoriesReady, candidate, label, @@ -379,10 +413,12 @@ export function useMentions( ownerProfiles: ownerProfilesQuery.data?.profiles, profiles, }), - ); + onRetry: candidate.action === "unavailable" ? retryMention : undefined, + })); }, [ activePersonaIds, agentDirectoriesReady, + retryMention, currentPubkey, mentionCandidatesWithTeams, mentionQuery, @@ -403,30 +439,60 @@ export function useMentions( const getDefaultAgentSuggestion = defaultAgentSuggestion; // Search hooks are keyed by the requested text. Wait for that request's // first page and initial directories, then keep exactly one displayed set. + const searchReady = + !canSearchGlobalUsers || + (!userSearchQuery.isPending && !userSearchQuery.isFetching); const resultsReady = - (channelId === null || - !!externalMembers || - (!membersQuery.isPending && !membersQuery.isFetching)) && - !managedAgentsQuery.isPending && - !managedAgentsQuery.isFetching && - !relayAgentsQuery.isPending && - !relayAgentsQuery.isFetching && - !personasQuery.isPending && - !personasQuery.isFetching && - !teamsQuery.isPending && - !teamsQuery.isFetching && - (!canSearchGlobalUsers || - (!userSearchQuery.isPending && !userSearchQuery.isFetching)); + searchReady && + (verificationFailed || + ((channelId === null || + !!externalMembers || + (!membersQuery.isPending && !membersQuery.isFetching)) && + !managedAgentsQuery.isPending && + !managedAgentsQuery.isFetching && + !relayAgentsQuery.isPending && + !relayAgentsQuery.isFetching && + !personasQuery.isPending && + !personasQuery.isFetching && + !teamsQuery.isPending && + !teamsQuery.isFetching && + searchReady)); const mentionSelection = useMentionSelection( query.request, matchingSuggestions, resultsReady, ); const { - suggestions, + suggestions: snapshotSuggestions, mentionSelectedIndex, isLoading: isMentionLoading, } = mentionSelection; + // Identity, label and order stay frozen. Availability is live evidence, + // not part of that snapshot's authority; a checking row can finish or retry + // without moving anyone's highlighted recipient. + const suggestions = React.useMemo( + () => + snapshotSuggestions.map((row) => { + const live = mentionCandidatesWithTeams.find((candidate) => + row.pubkey + ? candidate.pubkey === row.pubkey + : row.teamId + ? candidate.teamId === row.teamId + : candidate.personaId === row.personaId, + ); + return { + ...row, + action: live ? live.action : "unavailable", + presence: live?.presence ?? "unknown", + unavailableReason: + live?.unavailableReason ?? + (live ? undefined : "Access no longer available"), + onRetry: + live?.action === "unavailable" || !live ? retryMention : undefined, + }; + }), + [snapshotSuggestions, mentionCandidatesWithTeams, retryMention], + ); const isMentionOpen = mentionQuery !== null; // Recheck against this render's exact-key evidence even if a child retained // an older row/callback. A rejected selection must not establish draft intent. @@ -679,7 +745,7 @@ export function useMentions( () => selectedAgentMentionPubkeysRef.current, ).current; const revalidateMentionPubkeys = useAgentMentionRevalidation({ - agentPubkeys: agentIdentityPubkeys, + agentPubkeys: knownAgentPubkeys, getSelectedAgentPubkeys, currentPubkey, eligibilityScope: mentionChannelId diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index ac0cc0ba4cb..a287cf88546 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -563,3 +563,100 @@ test("agents without trustworthy provenance omit management provenance", () => { false, ); }); + +test("disabled current members expose retry and preserve collision ownership without notifying", async () => { + const React = await import("react"); + const { fireEvent, render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + let selected = 0, + retries = 0; + const view = render( + React.createElement(MentionAutocomplete, { + composerOwnsFocus: true, + selectedIndex: 0, + onSelect: () => selected++, + suggestions: [ + { + pubkey: "a".repeat(64), + displayName: "Scout", + isAgent: true, + agentProvenance: "managed-elsewhere", + ownerLabel: "You", + hasNameCollision: true, + action: "unavailable", + unavailableReason: "Could not verify access", + presence: "unknown", + onRetry: () => retries++, + }, + ], + }), + ); + const button = view.getByRole("button", { name: /^Unavailable Scout/ }); + assert.equal(button.disabled, true); + fireEvent.mouseDown(button); + fireEvent.click(button); + assert.equal(view.queryByRole("button", { name: /Always mention/ }), null); + assert.equal(selected, 0); + assert.ok(view.getByText("managed by You")); + assert.ok(view.getByText("Presence unknown")); + assert.ok( + view.getByTestId("mention-collision-npub").title.startsWith("npub1"), + ); + fireEvent.click( + view.getByRole("button", { name: "Retry access check for Scout" }), + ); + assert.equal(retries, 1); +}); + +test("evidence times out, retries explicitly, and forgets classification on scope change", async (t) => { + const { renderHook, act } = await import("@testing-library/react"); + const { useMentionEvidence } = await import("../lib/useMentionEvidence.ts"); + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10000 }); + let retries = 0; + const view = renderHook((props) => useMentionEvidence(props), { + initialProps: { + scope: "viewer:room", + request: {}, + agentKeys: new Set(["a"]), + directoryUpdatedAt: 10000, + directoryError: false, + retry: () => retries++, + }, + }); + assert.equal(view.result.current.verificationFailed, false); + act(() => t.mock.timers.tick(5000)); + assert.equal(view.result.current.verificationFailed, true); + view.rerender({ + scope: "viewer:room", + request: {}, + agentKeys: new Set(["a"]), + directoryUpdatedAt: 10000, + directoryError: false, + retry: () => retries++, + }); + assert.equal( + view.result.current.verificationFailed, + false, + "new completion gets a fresh verification window", + ); + act(() => t.mock.timers.tick(4999)); + assert.equal(view.result.current.verificationFailed, false); + act(() => t.mock.timers.tick(1)); + assert.equal(view.result.current.verificationFailed, true); + act(() => view.result.current.retryVerification()); + assert.equal(retries, 1); + assert.equal(view.result.current.verificationFailed, false); + view.rerender({ + scope: "viewer:other", + request: {}, + agentKeys: new Set(), + directoryUpdatedAt: 10000, + directoryError: false, + retry: () => retries++, + }); + assert.equal(view.result.current.knownAgentPubkeys.size, 0); + act(() => t.mock.timers.tick(180000)); + assert.equal(view.result.current.presenceFresh, false); + view.unmount(); + t.mock.timers.reset(); +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 085d7221cbb..a62b1ed68a4 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,4 +1,8 @@ -import type { MentionAction } from "@/features/messages/lib/mentionPresentation"; +import { + isMentionActionable, + type MentionAction, + type MentionPresence, +} from "../lib/mentionPresentation"; import * as React from "react"; import { Bot, ChevronRight, Pin, Users } from "lucide-react"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; @@ -21,8 +25,6 @@ import { truncatePubkey } from "@/shared/lib/pubkey"; import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { - action?: MentionAction; - hasNameCollision?: boolean; pubkey?: string; personaId?: string; teamId?: string; @@ -35,6 +37,13 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + action?: MentionAction; + unavailableReason?: string; + presence?: MentionPresence; + localLifecycle?: string; + localError?: boolean; + hasNameCollision?: boolean; + onRetry?: () => void; }; type MentionAutocompleteProps = { @@ -339,10 +348,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion, hasNameCollision, ); - const ownerLabel = - hasNameCollision && suggestion.agentProvenance - ? null - : suggestion.ownerLabel; + const ownerLabel = suggestion.ownerLabel; const collisionNpub = hasNameCollision && suggestion.pubkey ? safeNpub(suggestion.pubkey) @@ -352,10 +358,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion.isAgent || suggestion.role || ownerLabel || - suggestion.notInChannel, + suggestion.notInChannel || + suggestion.action, ); const canAlwaysAddress = Boolean( - onToggleAlwaysAddressAgent && + isMentionActionable(suggestion) && + onToggleAlwaysAddressAgent && suggestion.isAgent && suggestion.pubkey, ); @@ -377,14 +385,16 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ key={suggestionKey} > + {suggestion.action === "unavailable" && suggestion.onRetry ? ( + + ) : null} {canAlwaysAddress ? ( diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index 968ec9d196e..6d8e1cb428c 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -4,6 +4,9 @@ import { waitForAnimations } from "../helpers/animations"; const A = "11".repeat(32), B = "22".repeat(32); +const DENIED = "33".repeat(32), + UNKNOWN = "44".repeat(32), + INVITE = "55".repeat(32); const GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const OWNER = "deadbeef".repeat(8); const parent = process.env.MENTION_PARENT === "1"; @@ -84,7 +87,7 @@ test("fresh create and Add member then first @ uses governing directory refresh" await page.getByTestId("message-input").fill("@Fresh"); const row = page.getByTestId(`mention-suggestion-${A}`); await expect(row).toBeVisible(); - await expect(row).not.toContainText(/not in channel/i); + await expect(row).toContainText("Member · Mention"); // Same settled camera on the unchanged parent, even if no row is admitted. await page.waitForTimeout(400); await capture(page, "fresh-add"); @@ -205,3 +208,86 @@ test("Escape discards delayed picker results across navigation", async ({ await expect(page.getByTestId("mention-autocomplete-layer")).toBeHidden(); await expect(input).toBeEmpty(); }); + +test("already visible checking and denied members remain disabled beside permitted Invite", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [], + relayAgents: [ + { + pubkey: DENIED, + name: "Verify restricted", + ownerPubkey: "aa".repeat(32), + respondTo: "owner-only", + channelNames: ["general"], + }, + { + pubkey: INVITE, + name: "Verify available", + ownerPubkey: OWNER, + respondTo: "anyone", + status: "away", + }, + ], + searchProfiles: [ + { pubkey: DENIED, displayName: "Verify restricted", isAgent: true }, + { pubkey: UNKNOWN, displayName: "Verify pending", isAgent: true }, + ], + }); + await page.goto("/"); + await seedMembers(page, [DENIED, UNKNOWN]); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill("@Verify"); + if (!parent) + await expect( + page.getByTestId(`mention-suggestion-${INVITE}`), + ).toBeVisible(); + await page.waitForTimeout(400); + if (!parent) { + await expect( + page + .getByTestId(`mention-suggestion-${DENIED}`) + .locator("button") + .first(), + ).toBeDisabled(); + await expect( + page.getByTestId(`mention-suggestion-${UNKNOWN}`), + ).toContainText("Checking access"); + await expect( + page.getByTestId(`mention-suggestion-${INVITE}`), + ).toContainText("Invite…"); + } + await capture(page, "actions"); + if (!parent) { + await expect( + page.getByTestId(`mention-suggestion-${UNKNOWN}`), + ).toContainText("Unavailable", { timeout: 7000 }); + await expect( + page.getByRole("button", { + name: "Retry access check for Verify pending", + }), + ).toBeVisible(); + const identities = await page + .locator("[data-mention-suggestion-index]") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ); + await page + .getByRole("button", { name: "Retry access check for Verify pending" }) + .click(); + await expect( + page.getByTestId(`mention-suggestion-${UNKNOWN}`), + ).toContainText("Checking access"); + await expect + .poll(() => + page + .locator("[data-mention-suggestion-index]") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ), + ) + .toEqual(identities); + await expect(page.getByTestId("message-input")).toHaveText("@Verify"); + } +}); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 12891d97613..eea36e94885 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -438,8 +438,8 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect(relayProvenanceMarker).toBeVisible(); await expect(relayProvenanceMarker).toHaveText(""); await expect(relayProvenanceMarker.locator("svg")).toBeVisible(); - await expect(managedRow).not.toContainText("managed by you"); - await expect(relayRow).not.toContainText("managed by you"); + await expect(managedRow).toContainText("managed by you"); + await expect(relayRow).toContainText("managed by you"); await page.setViewportSize({ width: 760, height: 640 }); await expect(relayProvenanceMarker).toBeVisible(); @@ -1539,7 +1539,7 @@ test("managed relay-profile agents with member roles can be addressed explicitly ).toBeVisible(); }); -test("other-owned agents without a shared channel are hidden from mentions", async ({ +test("other-owned agents without a shared channel remain unavailable", async ({ page, }) => { await installMockBridge(page, { @@ -1560,17 +1560,24 @@ test("other-owned agents without a shared channel are hidden from mentions", asy const input = page.getByTestId("message-input"); await input.fill("@mira"); - const dropdown = autocomplete(page); await expect( - page.getByRole("status").filter({ hasText: "No mentions found" }), - ).toBeVisible(); - await expect( - dropdown.locator("[data-testid^=mention-suggestion-]"), - ).toHaveCount(0); + autocomplete(page).getByRole("button", { + name: "Checking mira", + exact: true, + }), + ).toBeDisabled(); + const action = autocomplete(page).getByRole("button", { + name: "Unavailable mira", + exact: true, + }); + // The product intentionally gives unknown evidence a five-second window. + await expect(action).toBeDisabled({ timeout: 7000 }); + await input.press("Tab"); + await expect(input).toHaveText("@mira"); await expect(input.locator(".mention-chip")).toHaveCount(0); }); -test("stale channel-member agents absent from managed and relay directories stay hidden", async ({ +test("stale channel-member agents absent from managed and relay directories remain unavailable", async ({ page, }) => { await installMockBridge(page, { userSearchDelayMs: 1_000 }); @@ -1582,11 +1589,19 @@ test("stale channel-member agents absent from managed and relay directories stay await input.fill("@mira"); await expect( - page.getByRole("status").filter({ hasText: "No mentions found" }), - ).toBeVisible(); - await expect( - autocomplete(page).locator("[data-testid^=mention-suggestion-]"), - ).toHaveCount(0); + autocomplete(page).getByRole("button", { + name: "Checking mira", + exact: true, + }), + ).toBeDisabled(); + const action = autocomplete(page).getByRole("button", { + name: "Unavailable mira", + exact: true, + }); + // The product intentionally gives unknown evidence a five-second window. + await expect(action).toBeDisabled({ timeout: 7000 }); + await input.press("Tab"); + await expect(input).toHaveText("@mira"); }); test("managed relay agents are visible in channel mentions regardless of relay policy", async ({ @@ -1621,7 +1636,7 @@ test("managed relay agents are visible in channel mentions regardless of relay p await expect(dropdown.getByText("agent")).toBeVisible(); }); -test("relay-only shared agents stay hidden from DM mentions", async ({ +test("relay-only shared agents remain unavailable from DM mentions", async ({ page, }) => { await page.goto("/"); @@ -1630,15 +1645,16 @@ test("relay-only shared agents stay hidden from DM mentions", async ({ await page.getByTestId("message-input").fill("@alice"); - await expect( - page.getByRole("status").filter({ hasText: "No mentions found" }), - ).toBeVisible(); - await expect( - autocomplete(page).locator("[data-testid^=mention-suggestion-]"), - ).toHaveCount(0); + const action = autocomplete(page).getByRole("button", { + name: "Unavailable alice", + exact: true, + }); + await expect(action).toBeDisabled(); + await page.getByTestId("message-input").press("Tab"); + await expect(page.getByTestId("message-input")).toHaveText("@alice"); }); -test("cached relay-agent choices cannot insert when channel authorization disappears", async ({ +test("cached relay-agent members become unavailable when channel authorization disappears", async ({ page, }) => { await installMockBridge(page, { userSearchDelayMs: 100 }); @@ -1674,9 +1690,24 @@ test("cached relay-agent choices cannot insert when channel authorization disapp await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); }, GENERAL_CHANNEL_ID); - await expect(aliceSuggestion).toBeVisible(); - await input.press("Tab"); + const action = aliceSuggestion.getByRole("button", { + name: "Unavailable alice", + exact: true, + }); + await expect(action).toBeDisabled(); + await expect( + aliceSuggestion.getByRole("button", { name: "Retry" }), + ).toBeEnabled(); + await expect( + aliceSuggestion.getByRole("button", { name: /automatic/i }), + ).toHaveCount(0); + await action.dispatchEvent("click"); + for (const key of ["Tab", "Enter"]) await input.press(key); await expect(input).toHaveText("@alice"); + await expect( + page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.alice.pubkey}`), + ).toHaveCount(0); + expect(await readOutgoingMentionPubkeys(page, "@alice")).toBeNull(); }); test("relay-only shared agents appear in forum mentions", async ({ page }) => { @@ -1829,6 +1860,9 @@ test("managed agents use the channel roster for membership labels", async ({ queryKey: ["channels"], exact: true, }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels", channelId, "members"], + }); }, { channelId: GENERAL_CHANNEL_ID, @@ -1843,12 +1877,21 @@ test("managed agents use the channel roster for membership labels", async ({ await expect(carlRow).toBeVisible(); await expect(carlRow.getByText("agent")).toBeVisible(); await expect(carlRow.getByText("not in channel")).toHaveCount(0); + await expect(carlRow).toContainText("Member · Mention"); + await expect(carlRow).not.toContainText("Invite"); }); test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({ page, }) => { await installMockBridge(page, { + searchProfiles: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + displayName: "quinn", + isAgent: true, + }, + ], relayAgentListErrors: Array(20).fill("mock directory unavailable"), relayAgents: [ { @@ -1862,21 +1905,44 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", }); await page.goto("/"); await page.getByTestId("channel-general").click(); + // Failed discovery cannot disclose an unknown directory-only identity. + // Seed a known channel member independently, so Retry has an existing row. + await page.evaluate( + async ({ channelId, pubkey }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels", channelId, "members"], + }); + }, + { channelId: GENERAL_CHANNEL_ID, pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY }, + ); const input = page.getByTestId("message-input"); await input.fill("@quinn"); await expect( - page.getByRole("status").filter({ hasText: "No mentions found" }), - ).toBeVisible(); + autocomplete(page).getByRole("button", { + name: "Unavailable quinn", + exact: true, + }), + ).toBeDisabled(); + await input.press("Tab"); + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); await page.evaluate(async () => { window.__BUZZ_E2E__.mock!.relayAgentListErrors = []; - await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ - queryKey: ["relay-agents"], - }); }); - await input.press("Escape"); - await input.fill("@quin"); - await expect(autocomplete(page).getByText("quinn")).toBeVisible(); + await autocomplete(page) + .getByRole("button", { name: "Retry access check for quinn", exact: true }) + .click(); + await expect( + autocomplete(page).getByRole("button", { + name: /^(Mention|Invite) quinn$/, + }), + ).toBeEnabled(); await page.evaluate(() => { window.__BUZZ_E2E__.mock ??= {}; @@ -2108,7 +2174,10 @@ test("selected relay agents are invited as bots before sending", async ({ await input.fill("@quinn"); const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); - await expect(quinnRow.getByText("not in channel")).toHaveCount(0); + await expect(quinnRow.getByText("not in channel")).toBeVisible(); + await expect(quinnRow).toContainText("Invite"); + await expect(quinnRow).not.toContainText("Member · Mention"); + await expect(quinnRow).toBeEnabled(); await quinnRow.click(); await page.keyboard.type("hello"); @@ -2119,11 +2188,17 @@ test("selected relay agents are invited as bots before sending", async ({ exact: true, }); await expect(inviteButton).toBeVisible(); + expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); + expect( + (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .some((entry) => entry.command === "add_channel_members"), + ).toBe(false); await inviteButton.click(); await expect .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + .toEqual([ALLOWLIST_RELAY_AGENT_PUBKEY]); const sendCommands = (await readCommandPayloadLog(page)).slice( baselinePayloadCount, ); diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 2af1784bc47..b7577b28e8e 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -86,3 +86,8 @@ This is display stability, not cached permission. Selection checks current exact-key access and team recipients; publication still revalidates authority. Recipient-label binding and highlight settlement remain independent of the picker's request lifecycle. + +Availability labels may resolve from Checking to Mention or Unavailable in place; +this never replaces an identity, label, order or selected index. Retry starts a +fresh request. Live access is checked again at selection, including for rows +whose display snapshot originally permitted mentioning.