From 9e5a546fd9440fa7e1015e213f9033eb5d677629 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 13:21:34 -0400 Subject: [PATCH 1/8] fix(desktop): wait for people search behind cold directories Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/mentionAdmissionJourney.test.mjs | 140 ++++++++++++++- .../messages/lib/buildMentionCandidates.ts | 128 ++++++++++---- .../messages/lib/mentionCandidates.ts | 11 +- .../messages/lib/mentionMemberPubkeys.ts | 1 + .../messages/lib/mentionPresentation.test.mjs | 162 ++++++++++++++++++ .../messages/lib/mentionPresentation.ts | 2 + .../features/messages/lib/mentionRanking.ts | 10 +- .../messages/lib/mentionSuggestionMapping.ts | 11 ++ .../messages/lib/useMentionEvidence.ts | 55 ++++++ .../features/messages/lib/useMentionQuery.ts | 3 + .../src/features/messages/lib/useMentions.ts | 112 +++++++++--- .../messages/ui/MentionAutocomplete.test.mjs | 97 +++++++++++ .../messages/ui/MentionAutocomplete.tsx | 74 ++++++-- desktop/tests/e2e/mention-picker.spec.ts | 95 ++++++++++ desktop/tests/e2e/mentions.spec.ts | 154 +++++++++++++---- docs/mention-editor.md | 5 + 16 files changed, 948 insertions(+), 112 deletions(-) create mode 100644 desktop/src/features/messages/lib/mentionPresentation.test.mjs create mode 100644 desktop/src/features/messages/lib/useMentionEvidence.ts diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index d72085e90d9..c4e57c9691a 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -115,6 +115,7 @@ const invoke = async (command, args) => { if (command === "sync_agents_to_active_huddle") return null; if (command === "list_relay_agents") { state.directoryCalls += 1; + if (state.heldDirectory) return state.heldDirectory; if (state.failDirectory) throw new Error("Directory unavailable"); return state.missingDirectory ? [] : [rawAgent()]; } @@ -228,7 +229,8 @@ async function setup(overrides = {}) { [["teams"], []], [["archivedIdentities"], { archived: [] }], ]) - client.setQueryData(key, data); + if (!(state.heldDirectory && key[0] === "relay-agents")) + client.setQueryData(key, data); const container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -281,6 +283,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", + ); }); } @@ -567,12 +574,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); }); @@ -618,7 +625,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()); @@ -668,3 +675,128 @@ 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); +}); + +test("cold directory expiry waits for required people search before installing choices", async () => { + let releaseDirectory, releaseSearch; + await setup({ + heldDirectory: new Promise((resolve) => { + releaseDirectory = resolve; + }), + pendingSearch: { + slow: new Promise((resolve) => { + releaseSearch = resolve; + }), + }, + }); + await act(async () => mention.updateMentionQuery("@Slow", 5)); + await act(async () => new Promise((resolve) => setTimeout(resolve, 5100))); + assert.equal( + mention.isMentionLoading, + true, + "directory expiry cannot complete a not-yet-enabled search", + ); + assert.deepEqual(mention.suggestions, []); + state.heldDirectory = null; + await act(async () => releaseDirectory([])); + await settle(); + assert.equal( + mention.isMentionLoading, + true, + "enabling search is not settlement", + ); + assert.deepEqual(mention.suggestions, []); + await act(async () => + releaseSearch({ users: [person(OTHER, "Slow Person")], next_cursor: null }), + ); + await settle(); + assert.equal(mention.isMentionLoading, false); + assert.deepEqual( + mention.suggestions.map((row) => row.pubkey), + [OTHER], + ); + assert.equal( + mention.handleMentionKeyDown(keyboard("Tab")).suggestion.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..7cb55cad377 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,62 @@ 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. + // A required search may still be disabled behind cold directories. Expiry + // can end directory verification, but cannot settle that first search page. + const searchReady = + !canSearchGlobalPeople || + (!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 +747,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 737b333088b..43fccbb11a9 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -1,10 +1,22 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +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"; +async function capture(page: Page, name: string) { + await waitForAnimations(page); + await page.screenshot({ + path: `test-results/mention-picker/${parent ? "before" : "after"}-${name}.png`, + clip: { x: 256, y: 380, width: 1024, height: 520 }, + }); +} async function seedMembers(page: Page, keys: string[]) { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -142,3 +154,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 510d46c1e48..31034f8bc11 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,53 @@ 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"); + // Cold nonempty search cannot settle before directory recovery. Retry for a + // known member is available from the explicit picker, which needs no search. + await expect(page.getByTestId("mention-autocomplete-layer")).toContainText( + "Loading", + ); + await input.fill(""); + await page + .getByRole("button", { name: "Mention someone", exact: true }) + .click(); 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).toBeEmpty(); + 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 +2183,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 +2197,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. From a6fb55dde43df43d976d71421cdb9a4013034395 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 13:40:07 -0400 Subject: [PATCH 2/8] fix(desktop): settle required search after directory errors Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/mentionAdmissionJourney.test.mjs | 24 ++- .../src/features/messages/lib/useMentions.ts | 9 +- desktop/tests/e2e/mentions.spec.ts | 197 +++++++++--------- 3 files changed, 129 insertions(+), 101 deletions(-) diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index c4e57c9691a..d10339f2311 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -229,7 +229,12 @@ async function setup(overrides = {}) { [["teams"], []], [["archivedIdentities"], { archived: [] }], ]) - if (!(state.heldDirectory && key[0] === "relay-agents")) + if ( + !( + (state.heldDirectory || state.coldDirectory) && + key[0] === "relay-agents" + ) + ) client.setQueryData(key, data); const container = document.createElement("div"); document.body.appendChild(container); @@ -676,7 +681,7 @@ test("Space completes an exact name but remains literal for partial and same-nam ); }); -for (const condition of ["denied", "missing", "failed"]) { +for (const condition of ["denied", "missing", "failed", "cold-failed"]) { test(`disabled ${condition} member rejects pointer, keyboard and new pin intent`, async () => { await setup({ owner: OTHER, @@ -684,9 +689,22 @@ for (const condition of ["denied", "missing", "failed"]) { directoryVisible: true, policy: condition === "denied" ? "owner-only" : "anyone", missingDirectory: condition === "missing", - failDirectory: condition === "failed", + failDirectory: condition.endsWith("failed"), + coldDirectory: condition === "cold-failed", + searchUsers: [person(OTHER, "Remote Person")], }); + await act(async () => mention.updateMentionQuery("@Remote", 7)); + await settle(); + assert.equal(mention.isMentionLoading, false); const row = rows()[0]; + if (condition === "cold-failed") { + assert.equal(row.action, "unavailable"); + assert.equal(typeof row.onRetry, "function"); + assert.equal( + mention.suggestions.some((s) => s.pubkey === OTHER), + false, + ); + } assert.equal(mention.canSelectMention(row), false); await act(async () => { picker.selectMentionSuggestion(row); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 7cb55cad377..4868211eb75 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -118,7 +118,14 @@ export function useMentions( const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; const userSearchQuery = useInfiniteUserSearchQuery(mentionQuery ?? "", { allowEmpty: true, - enabled: canSearchGlobalUsers && mentionQuery !== null, + // Terminal directory errors must allow required search to settle so known + // roster rows expose Unavailable/Retry. Discovery admission still requires + // successful directories below, independently of fetch enablement. + enabled: + canSearchGlobalPeople && + !managedAgentsQuery.isPending && + !relayAgentsQuery.isPending && + mentionQuery !== null, limit: MENTION_SUGGESTION_LIMIT, }); const userSearchResults = React.useMemo( diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 31034f8bc11..e40365b45d1 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1881,108 +1881,111 @@ test("managed agents use the channel roster for membership labels", async ({ 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: [ - { - pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, - name: "quinn", - respondTo: "allowlist", - respondToAllowlist: [MOCK_VIEWER_PUBKEY], - channelNames: ["general"], +for (const explicitPicker of [false, true]) { + test(`relay-agent directory errors fail closed and recover after a fresh fetch (${explicitPicker ? "explicit picker" : "typed query"})`, async ({ + page, + }) => { + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + displayName: "quinn", + isAgent: true, + }, + ], + relayAgentListErrors: Array(20).fill("mock directory unavailable"), + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + 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"], + }); }, - ], - }); - 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"); - // Cold nonempty search cannot settle before directory recovery. Retry for a - // known member is available from the explicit picker, which needs no search. - await expect(page.getByTestId("mention-autocomplete-layer")).toContainText( - "Loading", - ); - await input.fill(""); - await page - .getByRole("button", { name: "Mention someone", exact: true }) - .click(); - await expect( - autocomplete(page).getByRole("button", { - name: "Unavailable quinn", - exact: true, - }), - ).toBeDisabled(); - await input.press("Tab"); - await expect(input).toBeEmpty(); - await expect(input.locator(".mention-chip")).toHaveCount(0); + { channelId: GENERAL_CHANNEL_ID, pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY }, + ); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + if (explicitPicker) { + await input.fill(""); + await page + .getByRole("button", { name: "Mention someone", exact: true }) + .click(); + } + await expect( + autocomplete(page).getByRole("button", { + name: "Unavailable quinn", + exact: true, + }), + ).toBeDisabled(); + await input.press("Tab"); + if (explicitPicker) await expect(input).toBeEmpty(); + else await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); - await page.evaluate(async () => { - window.__BUZZ_E2E__.mock!.relayAgentListErrors = []; - }); - 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(async () => { + window.__BUZZ_E2E__.mock!.relayAgentListErrors = []; + }); + 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 ??= {}; - window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000; - void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ - queryKey: ["relay-agents"], + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000; + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); }); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("fetching"); + await expect(autocomplete(page).getByText("quinn")).toBeVisible({ + timeout: 200, + }); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("idle"); + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); }); - await expect - .poll(async () => - page.evaluate( - () => - window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) - ?.fetchStatus, - ), - ) - .toBe("fetching"); - await expect(autocomplete(page).getByText("quinn")).toBeVisible({ - timeout: 200, - }); - await expect - .poll(async () => - page.evaluate( - () => - window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) - ?.fetchStatus, - ), - ) - .toBe("idle"); - await expect(autocomplete(page).getByText("quinn")).toBeVisible(); -}); +} test("relay-only allowlisted agents emit a p tag when sent", async ({ page, From 3825f894d08acb5a12d609fbb100da7d982274f2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 15:47:16 -0400 Subject: [PATCH 3/8] test(desktop): settle hover surfaces before comparing colors Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/message-feedback-snapshots.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/tests/e2e/message-feedback-snapshots.spec.ts b/desktop/tests/e2e/message-feedback-snapshots.spec.ts index 18776d7f256..76f1b975a2f 100644 --- a/desktop/tests/e2e/message-feedback-snapshots.spec.ts +++ b/desktop/tests/e2e/message-feedback-snapshots.spec.ts @@ -101,10 +101,12 @@ test("profile hover uses the channel hover surface", async ({ page }) => { const profile = page.getByTestId("sidebar-profile-card"); const channel = page.getByTestId("channel-random"); await channel.hover(); + await waitForAnimations(page); const channelHoverColor = await channel.evaluate( (element) => getComputedStyle(element).backgroundColor, ); await profile.hover(); + await waitForAnimations(page); await expect(profile).toHaveCSS("background-color", channelHoverColor); await waitForAnimations(page); From 27593788c4f21a74b1ccb994b2e926bf69b5cfcd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 16:45:17 -0400 Subject: [PATCH 4/8] test(desktop): bind workflow message owner and settled geometry Keep the outgoing trigger input separate from the new message textarea, sample match controls together after inspector settlement, and verify the persisted original trigger and step text before reopen. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../tests/e2e/workflow-local-controls.spec.ts | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..bcf619f1d66 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -63,7 +63,10 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // The outgoing trigger input has the same label during inspector exit. + const message = dialog.locator("textarea#wf-step-0-text"); + await expect(message).toBeVisible(); + await message.fill("Workflow notification"); } async function createEnabled( @@ -288,9 +291,30 @@ test("round-trips and reopens structured message-text conditions", async ({ await openTriggerInspector(dialog); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); - const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); - const secondOperatorBox = await operatorButtons.nth(1).boundingBox(); - const thirdOperatorBox = await operatorButtons.nth(2).boundingBox(); + // Motion updates inspector geometry in JS; sample all boxes in the same frame. + const boxes = await operatorButtons.evaluateAll(async (buttons) => { + if (buttons.length < 3) throw new Error("Missing workflow match operators"); + const inspector = buttons[0].closest( + '[data-testid="workflow-node-inspector"]', + ); + if (!inspector) throw new Error("Missing workflow inspector"); + let previous = ""; + let stable = 0; + for (let frame = 0; frame < 120; frame++) { + await new Promise(requestAnimationFrame); + const boxes = buttons + .slice(0, 3) + .map((button) => button.getBoundingClientRect().toJSON()); + const style = getComputedStyle(inspector); + const sample = { boxes, width: style.width, transform: style.transform }; + const current = JSON.stringify(sample); + stable = current === previous ? stable + 1 : 0; + if (stable >= 2) return boxes; + previous = current; + } + throw new Error("Workflow inspector geometry did not settle"); + }); + const [firstOperatorBox, secondOperatorBox, thirdOperatorBox] = boxes; expect(firstOperatorBox).not.toBeNull(); expect(secondOperatorBox).not.toBeNull(); expect(thirdOperatorBox).not.toBeNull(); @@ -315,6 +339,17 @@ test("round-trips and reopens structured message-text conditions", async ({ await addMessageStep(page, dialog); await createEnabled(page, dialog); + const savedYaml = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((candidate) => candidate.command === "create_workflow"); + return (call?.payload as { yamlDefinition?: string } | undefined) + ?.yamlDefinition; + }); + const saved = parseYaml(savedYaml ?? ""); + expect(saved.name).toBe(name); + expect(saved.trigger.filter).toBe(expression); + expect(saved.steps[0].text).toBe("Workflow notification"); const reopened = await reopenWorkflow(page, name); await openTriggerInspector(reopened); await expect( From be1d594f466434cdaeabd6aa4999b25b0a6a027b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 10:25:06 -0400 Subject: [PATCH 5/8] test(desktop): clear the parked error toast before the DM retry The first send parks the pointer over the bottom-right error toast, which overlaps the send button; Sonner pauses its dismiss timer while the toaster is hovered, so the native retry click is intercepted for the full timeout. Move the cursor away and wait for the transient toast to clear naturally before retrying, mirroring the adjacent agent-startup-failure test. The injected first-send failure and every destination assertion are unchanged. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/channels.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index db76be349e9..6bc079dbd3a 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1063,6 +1063,15 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { await input.fill(retryMessage); const retryBaseline = commandsAfterFailure.length; + // The first send left the cursor parked over the bottom-right error toast, + // which overlaps the send button. Sonner pauses its dismiss timer while the + // toaster is hovered, so move the cursor away and let the transient toast + // clear before retrying — otherwise the retry click is intercepted for the + // full timeout. + await page.mouse.move(0, 0); + await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { + timeout: 10_000, + }); await page.getByTestId("send-message").click(); await expect(page.getByTestId("chat-title")).toHaveText("charlie"); From f1af1b88c6458d40080529e8d71a0bdec1868a78 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 11:37:27 -0400 Subject: [PATCH 6/8] test(desktop): clear the explicit-picker draft with native keypresses The explicit-picker setup in the relay-agent directory-errors spec cleared the seeded draft with fill(""), whose programmatic selectAll can lose the selection to ProseMirror's own selection sync and leave "@quinn" behind in CI, exhausting retries at the picker-empty assertion. Clear the draft with native ControlOrMeta+A and Backspace so the editor applies both steps in its own transaction, and gate immediately on toBeEmpty before opening the picker. Every original directory-error, disabled-row, Tab-completion and retry-recovery assertion is unchanged; no production code, timeout or mock change. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/mentions.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e40365b45d1..14091fca181 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1924,7 +1924,14 @@ for (const explicitPicker of [false, true]) { const input = page.getByTestId("message-input"); await input.fill("@quinn"); if (explicitPicker) { - await input.fill(""); + // Clear the draft with native select-all + Backspace instead of + // fill(""): the programmatic selectAll inside fill can lose the + // selection to ProseMirror's own selection sync and leave "@quinn" + // behind in CI. Real key events let the editor apply both steps + // itself. + await input.press("ControlOrMeta+A"); + await input.press("Backspace"); + await expect(input).toBeEmpty(); await page .getByRole("button", { name: "Mention someone", exact: true }) .click(); From b1a429e5c48f1eeb76154b0de95d3d9c7f92a11f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 18:25:04 -0400 Subject: [PATCH 7/8] fix(desktop): preserve mention eligibility through retry settlement Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/mentionAdmissionJourney.test.mjs | 51 +++++++ .../lib/buildMentionCandidates.test.mjs | 41 ++++++ .../messages/lib/buildMentionCandidates.ts | 31 +++-- .../messages/lib/mentionPresentation.test.mjs | 1 + .../messages/lib/mentionPresentation.ts | 7 +- .../messages/lib/useMentionEvidence.ts | 46 ++++++- .../src/features/messages/lib/useMentions.ts | 23 +++- .../messages/ui/MentionAutocomplete.test.mjs | 130 +++++++++++++++++- .../messages/ui/MentionAutocomplete.tsx | 37 ++++- .../messages/ui/NonMemberMentionDialog.tsx | 2 +- .../ui/useMentionSendFlow.authority.test.mjs | 21 +++ .../ui/useMentionSendFlow.test-support.mjs | 10 +- desktop/tests/e2e/mention-picker.spec.ts | 22 ++- desktop/tests/e2e/mentions.spec.ts | 68 +++++++++ 14 files changed, 458 insertions(+), 32 deletions(-) diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index d10339f2311..f451e614460 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -818,3 +818,54 @@ test("cold directory expiry waits for required people search before installing c OTHER, ); }); + +test("cached allowed expiry blocks every retained choice until fresh retry settles", async () => { + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + policy: "anyone", + }); + await act(async () => mention.updateMentionQuery("@Remote", 7)); + await settle(); + const retained = rows()[0]; + assert.equal(retained.action, "mention"); + await act(async () => new Promise((resolve) => setTimeout(resolve, 5100))); + assert.equal(rows()[0].pubkey, retained.pubkey); + assert.equal(rows()[0].action, "unavailable"); + assert.equal( + rows()[0].unavailableReason, + "Could not verify access. Retry to check again.", + ); + const assertBlocked = async () => { + assert.equal(mention.canSelectMention(retained), false); + await act(async () => { + picker.selectMentionSuggestion(retained); + picker.toggleAlwaysAddressAgent(retained); + }); + 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, []); + }; + await assertBlocked(); + let release; + state.heldDirectory = new Promise((resolve) => { + release = resolve; + }); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + await assertBlocked(); + state.heldDirectory = null; + await act(async () => release([rawAgent()])); + await settle(); + assert.equal(rows()[0].action, "mention"); + assert.equal(rows()[0].pubkey, retained.pubkey); + assert.equal(mention.mentionSelectedIndex, 0); + assert.equal(mention.canSelectMention(retained), true); +}); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index 250e40e0a38..5275316cc59 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -130,3 +130,44 @@ test("global search results join only while global search is enabled", () => { assert.equal(searched[0].displayName, "Dana"); assert.equal(searched[0].isGlobalSearchResult, true); }); + +test("cached allowed agents lose actionability on expiry and during retry", () => { + const cached = input({ + members: [{ pubkey: AGENT_PUBKEY, displayName: "Scout", isAgent: true }], + relayAgents: [ + { pubkey: AGENT_PUBKEY, name: "Scout", ownerPubkey: MEMBER_PUBKEY }, + ], + mentionableAgentPubkeys: new Set([AGENT_PUBKEY]), + }); + assert.equal(buildMentionCandidates(cached)[0].action, "mention"); + const expired = buildMentionCandidates({ + ...cached, + verificationFailed: true, + })[0]; + assert.equal(expired.action, "unavailable"); + assert.equal( + expired.unavailableReason, + "Could not verify access. Retry to check again.", + ); + assert.equal( + buildMentionCandidates({ ...cached, verificationPending: true })[0].action, + "checking", + ); + assert.equal(buildMentionCandidates(cached)[0].action, "mention"); +}); + +test("nonmembers promise Invite only with destination add authority", () => { + const nonmember = input({ + canSearchGlobalUsers: true, + userSearchResults: [{ pubkey: SEARCHED_PUBKEY, displayName: "Visitor" }], + }); + assert.equal( + buildMentionCandidates(nonmember)[0].action, + "mention-without-invite", + ); + assert.equal( + buildMentionCandidates({ ...nonmember, canInviteNonMembers: true })[0] + .action, + "invite", + ); +}); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 0fc0540f805..1f38a4fad0a 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -20,6 +20,8 @@ export type BuildMentionCandidatesInput = { activeAgentPubkeys: ReadonlySet; knownAgentPubkeys?: ReadonlySet; verificationFailed?: boolean; + verificationPending?: boolean; + canInviteNonMembers?: boolean; presenceFresh?: boolean; activePersonaById: ReadonlyMap; /** Already narrowed to `isActive` personas. */ @@ -55,6 +57,8 @@ export function buildMentionCandidates({ activeAgentPubkeys, knownAgentPubkeys = new Set(), verificationFailed = false, + verificationPending = false, + canInviteNonMembers = false, presenceFresh = true, activePersonaById, activePersonas, @@ -262,15 +266,21 @@ export function buildMentionCandidates({ hasEvidence && (mentionableAgentPubkeys.has(key) || memberPolicyAllows); const action = - !isAgent || allowed - ? isMember - ? "mention" - : "invite" - : ready && hasEvidence - ? "unavailable" - : verificationFailed - ? "unavailable" - : "checking"; + isAgent && verificationFailed + ? "unavailable" + : isAgent && verificationPending + ? "checking" + : !isAgent || allowed + ? isMember + ? "mention" + : canInviteNonMembers + ? "invite" + : "mention-without-invite" + : ready && hasEvidence + ? "unavailable" + : verificationFailed + ? "unavailable" + : "checking"; return { ...candidate, isAgent, @@ -283,7 +293,7 @@ export function buildMentionCandidates({ action, unavailableReason: action === "unavailable" - ? ready && hasEvidence + ? !verificationFailed && ready && hasEvidence ? "This agent does not permit you to mention it here." : "Could not verify access. Retry to check again." : action === "checking" @@ -307,6 +317,7 @@ export function buildMentionCandidates({ candidate.kind !== "identity" || candidate.isMember || candidate.action === "invite" || + candidate.action === "mention-without-invite" || (candidate.isManagedAgent && candidate.action === "checking"), ); } diff --git a/desktop/src/features/messages/lib/mentionPresentation.test.mjs b/desktop/src/features/messages/lib/mentionPresentation.test.mjs index 434d4868a77..864cb2b551b 100644 --- a/desktop/src/features/messages/lib/mentionPresentation.test.mjs +++ b/desktop/src/features/messages/lib/mentionPresentation.test.mjs @@ -120,6 +120,7 @@ test("fresh roster removal beats stale directory and channel membership; owned n relayAgents: [relay()], mentionableAgentPubkeys: new Set([A]), memberPubkeys: new Set([A]), + canInviteNonMembers: true, }), ); assert.equal(row.isMember, false); diff --git a/desktop/src/features/messages/lib/mentionPresentation.ts b/desktop/src/features/messages/lib/mentionPresentation.ts index e2151e54942..4f2172abb54 100644 --- a/desktop/src/features/messages/lib/mentionPresentation.ts +++ b/desktop/src/features/messages/lib/mentionPresentation.ts @@ -1,7 +1,12 @@ import type { MentionCandidate } from "./mentionCandidates"; /** Presentation only. Publication still performs fresh authorization. */ -export type MentionAction = "mention" | "invite" | "checking" | "unavailable"; +export type MentionAction = + | "mention" + | "invite" + | "mention-without-invite" + | "checking" + | "unavailable"; export type MentionPresence = "online" | "away" | "offline" | "unknown"; export function isMentionActionable(candidate: { action?: MentionAction }) { diff --git a/desktop/src/features/messages/lib/useMentionEvidence.ts b/desktop/src/features/messages/lib/useMentionEvidence.ts index 0dc81a85bff..b6da81f01cd 100644 --- a/desktop/src/features/messages/lib/useMentionEvidence.ts +++ b/desktop/src/features/messages/lib/useMentionEvidence.ts @@ -14,11 +14,26 @@ export function useMentionEvidence({ agentKeys: ReadonlySet; directoryUpdatedAt: number; directoryError: boolean; - retry: () => void; + retry: () => void | Promise; }) { 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 generation = React.useRef({ scope, token: 0 }); + if (generation.current.scope !== scope) { + generation.current = { scope, token: generation.current.token + 1 }; + } + const [retryState, setRetryState] = React.useState<{ + scope: string; + pending: boolean; + failed: boolean; + } | null>(null); + React.useEffect( + () => () => { + generation.current.token += 1; + }, + [], + ); const [attempt, setAttempt] = React.useState(0); const [expired, setExpired] = React.useState<{ request: object; @@ -38,13 +53,38 @@ export function useMentionEvidence({ return () => clearTimeout(timer); }, [directoryUpdatedAt]); const retryVerification = React.useCallback(() => { + const token = ++generation.current.token; + setRetryState({ scope, pending: true, failed: false }); setExpired(null); setAttempt((value) => value + 1); - retry(); - }, [retry]); + void Promise.resolve() + .then(retry) + .then( + () => { + if ( + generation.current.scope !== scope || + generation.current.token !== token + ) + return; + setRetryState({ scope, pending: false, failed: false }); + setExpired(null); + setAttempt((value) => value + 1); + }, + () => { + if ( + generation.current.scope !== scope || + generation.current.token !== token + ) + return; + setRetryState({ scope, pending: false, failed: true }); + }, + ); + }, [retry, scope]); return { knownAgentPubkeys: known.current.keys, + verificationPending: retryState?.scope === scope && retryState.pending, verificationFailed: + (retryState?.scope === scope && retryState.failed) || directoryError || (!!request && expired?.request === request && diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 4868211eb75..1134f01024e 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 { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import { useMentionEvidence } from "./useMentionEvidence"; import * as React from "react"; import { @@ -78,6 +79,7 @@ export function useMentions( profiles?: UserProfileLookup, options?: UseMentionsOptions, ) { + const canInviteNonMembers = useCanAddChannelMembers(channelId); const identityQuery = useIdentityQuery(); const currentPubkey = identityQuery.data?.pubkey ? normalizePubkey(identityQuery.data.pubkey) @@ -256,10 +258,15 @@ export function useMentions( }), [managedAgentPubkeys, members, profiles, relayAgentsQuery.data], ); - const retryDirectory = React.useCallback(() => { - void relayAgentsQuery.refetch(); - void managedAgentsQuery.refetch(); - void membersQuery.refetch(); + const retryDirectory = React.useCallback(async () => { + const results = await Promise.all([ + relayAgentsQuery.refetch(), + managedAgentsQuery.refetch(), + membersQuery.refetch(), + ]); + if (results.some((result) => result.isError)) { + throw new Error("Could not verify mention access"); + } }, [ relayAgentsQuery.refetch, managedAgentsQuery.refetch, @@ -268,6 +275,7 @@ export function useMentions( const { knownAgentPubkeys, verificationFailed, + verificationPending, presenceFresh, retryVerification, } = useMentionEvidence({ @@ -289,6 +297,8 @@ export function useMentions( activeAgentPubkeys, knownAgentPubkeys, verificationFailed, + verificationPending, + canInviteNonMembers, presenceFresh, activePersonaById, activePersonas, @@ -315,6 +325,8 @@ export function useMentions( activePersonaById, knownAgentPubkeys, verificationFailed, + verificationPending, + canInviteNonMembers, presenceFresh, activeAgentPubkeys, activePersonas, @@ -398,8 +410,7 @@ export function useMentions( searchableNamesLowerRef.current = searchableNamesLower; const retryMention = React.useCallback(() => { retryVerification(); - query.refresh(); - }, [retryVerification, query.refresh]); + }, [retryVerification]); const matchingSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index a287cf88546..ec1c4089212 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -643,7 +643,7 @@ test("evidence times out, retries explicitly, and forgets classification on scop 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()); + await act(async () => view.result.current.retryVerification()); assert.equal(retries, 1); assert.equal(view.result.current.verificationFailed, false); view.rerender({ @@ -660,3 +660,131 @@ test("evidence times out, retries explicitly, and forgets classification on scop view.unmount(); t.mock.timers.reset(); }); + +test("unavailable reasons persist and describe both disabled choice and retry", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const { TooltipProvider } = await import("@/shared/ui/tooltip"); + for (const reason of [ + "This agent does not permit you to mention it here.", + "Could not verify access. Retry to check again.", + ]) { + const view = render( + React.createElement( + TooltipProvider, + null, + React.createElement(MentionAutocomplete, { + suggestions: [ + { + pubkey: "a", + displayName: "Scout", + isAgent: true, + action: "unavailable", + unavailableReason: reason, + onRetry: () => {}, + }, + ], + selectedIndex: 0, + composerOwnsFocus: true, + onSelect: () => {}, + }), + ), + ); + const choice = view.getByRole("button", { name: "Unavailable Scout" }); + const retry = view.getByRole("button", { + name: "Retry access check for Scout", + }); + const description = view.getByText(reason); + assert.equal(choice.getAttribute("aria-describedby"), description.id); + assert.equal(retry.getAttribute("aria-describedby"), description.id); + assert.equal(choice.tabIndex, -1); + assert.equal(choice.disabled, true); + assert.equal(retry.tabIndex, 0); + assert.equal(description.hidden, false); + view.unmount(); + } +}); + +test("retry fences cached evidence until successful lookup settlement", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { useMentionEvidence } = await import("../lib/useMentionEvidence.ts"); + let resolve; + const lookup = new Promise((done) => { + resolve = done; + }); + const view = renderHook(() => + useMentionEvidence({ + scope: "viewer:room", + request: null, + agentKeys: new Set(["a"]), + directoryUpdatedAt: 10000, + directoryError: false, + retry: () => lookup, + }), + ); + await act(async () => view.result.current.retryVerification()); + assert.equal(view.result.current.verificationPending, true); + view.rerender(); + assert.equal( + view.result.current.verificationPending, + true, + "unchanged cache cannot settle retry", + ); + await act(async () => resolve()); + assert.equal(view.result.current.verificationPending, false); + assert.equal(view.result.current.verificationFailed, false); + view.unmount(); +}); + +test("retry errors remain unavailable and late settlement cannot clear newer or scoped checks", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { useMentionEvidence } = await import("../lib/useMentionEvidence.ts"); + const pending = []; + const retry = () => + new Promise((resolve, reject) => pending.push({ resolve, reject })); + const props = { + scope: "viewer:room", + request: {}, + agentKeys: new Set(["a"]), + directoryUpdatedAt: Date.now(), + directoryError: false, + retry, + }; + const view = renderHook((value) => useMentionEvidence(value), { + initialProps: props, + }); + await act(async () => view.result.current.retryVerification()); + await act(async () => pending[0].reject(new Error("lookup failed"))); + assert.equal(view.result.current.verificationPending, false); + assert.equal(view.result.current.verificationFailed, true); + view.rerender(props); + assert.equal( + view.result.current.verificationFailed, + true, + "cached readiness does not clear failure", + ); + await act(async () => view.result.current.retryVerification()); + await act(async () => view.result.current.retryVerification()); + await act(async () => pending[1].resolve()); + assert.equal( + view.result.current.verificationPending, + true, + "old success cannot settle newer retry", + ); + view.rerender({ + ...props, + scope: "viewer:other", + request: {}, + agentKeys: new Set(), + }); + await act(async () => view.result.current.retryVerification()); + await act(async () => pending[2].reject(new Error("old scope failed"))); + assert.equal(view.result.current.verificationPending, true); + assert.equal(view.result.current.verificationFailed, false); + assert.equal(view.result.current.knownAgentPubkeys.size, 0); + await act(async () => pending[3].resolve()); + assert.equal(view.result.current.verificationPending, false); + assert.equal(view.result.current.verificationFailed, false); + view.unmount(); +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a62b1ed68a4..00d92a4ce0f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -115,6 +115,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ const optionsSurfaceRef = React.useRef(null); const listRef = React.useRef(null); const optionsId = React.useId(); + const reasonIdPrefix = React.useId(); const keepPinnedSwitchId = React.useId(); const [optionsOpen, setOptionsOpen] = React.useState(false); const handledOptionsRequestRef = React.useRef(0); @@ -348,6 +349,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion, hasNameCollision, ); + const reasonId = `${reasonIdPrefix}-${suggestionKey}-reason`; const ownerLabel = suggestion.ownerLabel; const collisionNpub = hasNameCollision && suggestion.pubkey @@ -387,7 +389,9 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ {suggestion.action === "unavailable" && suggestion.onRetry ? ( @@ -520,8 +534,21 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ type="button" className="px-2 text-xs text-muted-foreground" aria-label={`Retry access check for ${suggestion.displayName}`} + aria-describedby={ + suggestion.unavailableReason ? reasonId : undefined + } onMouseDown={(event) => event.preventDefault()} - onClick={suggestion.onRetry} + onClick={() => { + // Retry disappears while checking. Transfer keyboard focus + // before it unmounts so the composer retains its chooser. + rootRef.current + ?.closest("form") + ?.querySelector( + '[data-testid="message-input"]', + ) + ?.focus(); + suggestion.onRetry?.(); + }} > Retry diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index c72686a6422..fb6c0867367 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -10,7 +10,7 @@ import { Button } from "@/shared/ui/button"; import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; type NonMemberMentionDialogProps = { - /** False in a private channel the viewer doesn't own/administer. */ + /** Whether the destination permits this viewer to add people. */ canInvite: boolean; error: string | null; isInvitePending: boolean; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs index 6621b43cd28..cd14c5e439a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs @@ -248,3 +248,24 @@ for (const extractor of [ } }); } + +// DMs bypass the channel invitation prompt: preserve the existing send contract. +test("DM nonmember sends directly without invitation side effects", async () => { + const s = await setup(); + s.dismiss(); + s.options.channelType = "dm"; + s.options.mentions.isAgentPubkey = () => false; + s.control.canInvite = false; + s.rerender(); + await s.act(async () => + s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + }), + ); + assert.equal(s.result.current.nonMemberPromptProps.open, false); + assert.equal(s.events("add").length, 0); + assert.equal(s.events("SEND").length, 1); + assert.deepEqual(s.events("SEND")[0][2], [KEY]); +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs index 305afd4f986..81436833195 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -70,7 +70,13 @@ export async function setup({ lifecycle = false } = {}) { dom.window.localStorage.clear(); draftStore.initDraftStore("test-author", "wss://test.example"); const calls = []; - const control = { prepare: null, add: null, publish: null, inventory: null }; + const control = { + prepare: null, + add: null, + publish: null, + inventory: null, + canInvite: true, + }; const refs = [{ displayName: "RemoteScout", pubkey: KEY, isAgent: true }]; const query = { data: [], @@ -125,7 +131,7 @@ export async function setup({ lifecycle = false } = {}) { useAddChannelMembersMutation: () => mutation, }, "@/features/channels/useCanAddChannelMembers": { - useCanAddChannelMembers: () => true, + useCanAddChannelMembers: () => control.canInvite, }, "@/features/channels/lib/channelMemberAdmission": {}, "@/features/messages/lib/dmThreadAgentMentionError": { diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index 43fccbb11a9..8a86ffded39 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -219,9 +219,25 @@ test("already visible checking and denied members remain disabled beside permitt .evaluateAll((rows) => rows.map((row) => row.getAttribute("data-testid")), ); - await page - .getByRole("button", { name: "Retry access check for Verify pending" }) - .click(); + const retry = page.getByRole("button", { + name: "Retry access check for Verify pending", + }); + await expect(retry).toHaveAccessibleDescription( + "Could not verify access. Retry to check again.", + ); + await page.getByTestId("message-input").press("Shift+Tab"); + await expect(page.getByTestId("mention-options-trigger")).toBeFocused(); + // Ordinary traversal from the existing Options entry, not a second focus stop. + for ( + let step = 0; + step < 4 && + !(await retry.evaluate((el) => el === document.activeElement)); + step++ + ) { + await page.keyboard.press("Tab"); + } + await expect(retry).toBeFocused(); + await page.keyboard.press("Enter"); await expect( page.getByTestId(`mention-suggestion-${UNKNOWN}`), ).toContainText("Checking access"); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 14091fca181..40fbe78a43e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -3280,11 +3280,15 @@ test("sent non-member person mention uses the normal mention style", async ({ const input = page.getByTestId("message-input"); await input.fill("Loop in @out"); + const baselineCommands = await readCommandLog(page); const dropdown = autocomplete(page); await expect(dropdown.getByText("outsider")).toBeVisible(); + await expect(dropdown).toContainText("Mention without inviting"); + await expect(dropdown).not.toContainText("Invite…"); await dropdown.getByText("outsider", { exact: true }).click(); await expect(input).toHaveText("Loop in @outsider "); await page.keyboard.type(" please"); + const content = await input.innerText(); await page.getByTestId("send-message").click(); const mentionChip = page @@ -3293,6 +3297,24 @@ test("sent non-member person mention uses the normal mention style", async ({ .locator("[data-mention]", { hasText: "outsider" }); await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); + await expect(page.getByRole("alertdialog")).toBeHidden(); + expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( + commandCount(baselineCommands, "add_channel_members"), + ); + const signed = await page.evaluate( + (content) => + window.__BUZZ_E2E_SIGNED_EVENTS__?.find( + (event) => event.content === content, + ), + content, + ); + expect(signed?.tags.filter((tag) => tag[0] === "h")).toEqual([ + ["h", "7eb9f239-9393-50b0-bd76-d85eef0511c7"], + ]); + expect(await readOutgoingMentionPubkeys(page, content)).toEqual([ + TEST_IDENTITIES.outsider.pubkey, + TEST_IDENTITIES.bob.pubkey, + ]); }); test("sent managed non-member agent mention uses the agent mention style", async ({ @@ -3974,3 +3996,49 @@ for (const channel of ["general", "watercooler"]) { await expect(input).toHaveText("hello @bo"); }); } + +for (const action of ["Invite", "Send anyway", "Cancel"]) { + test(`private-channel active member nonmember mention: ${action}`, async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-secret-projects").click(); + await expect(page.getByTestId("chat-title")).toHaveText("secret-projects"); + const baseline = await readCommandLog(page); + const input = page.getByTestId("message-input"); + await input.fill("Private @out"); + const dropdown = autocomplete(page); + await expect(dropdown).toContainText("Invite…"); + await dropdown.getByText("outsider", { exact: true }).click(); + const draft = await input.innerText(); + const content = draft.trim(); + await page.getByTestId("send-message").click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Invite", exact: true }), + ).toBeEnabled(); + if (action === "Cancel") await page.keyboard.press("Escape"); + else + await dialog + .getByRole("button", { + name: action === "Send anyway" ? "Do nothing" : action, + exact: true, + }) + .click(); + await expect(dialog).toBeHidden(); + if (action === "Cancel") { + await expect(input).toHaveText(draft); + expect(await readOutgoingMentionPubkeys(page, content)).toBeNull(); + } else { + await expect(input).toBeEmpty(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toEqual(action === "Invite" ? [TEST_IDENTITIES.outsider.pubkey] : []); + } + expect( + commandCount(await readCommandLog(page), "add_channel_members") - + commandCount(baseline, "add_channel_members"), + ).toBe(action === "Invite" ? 1 : 0); + }); +} From 479bbe0f17d7635c48e00b5f7280350b1d8b2dba Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 18:54:40 -0400 Subject: [PATCH 8/8] fix(desktop): retain exact nonmember mention evidence Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/mentionAdmissionJourney.test.mjs | 187 +++++++++++++----- .../messages/lib/buildMentionCandidates.ts | 27 ++- .../src/features/messages/lib/useMentions.ts | 23 ++- desktop/tests/e2e/mention-picker.spec.ts | 10 + docs/mention-editor.md | 8 +- 5 files changed, 195 insertions(+), 60 deletions(-) diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index f451e614460..9da1edcaf98 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -819,53 +819,152 @@ test("cold directory expiry waits for required people search before installing c ); }); -test("cached allowed expiry blocks every retained choice until fresh retry settles", async () => { - await setup({ - owner: OTHER, - visible: true, - directoryVisible: true, - policy: "anyone", +for (const visible of [true, false]) { + test(`cached allowed ${visible ? "member" : "relay-only nonmember"} expiry blocks every retained choice until fresh retry settles`, async () => { + await setup({ + owner: OTHER, + visible, + directoryVisible: true, + policy: "anyone", + }); + await act(async () => mention.updateMentionQuery("@Remote", 7)); + await settle(); + const retained = rows()[0]; + assert.equal(retained.action, visible ? "mention" : "invite"); + const identities = mention.suggestions.map((row) => [ + row.pubkey, + row.personaId, + row.teamId, + row.displayName, + ]); + await act(async () => new Promise((resolve) => setTimeout(resolve, 5100))); + assert.equal(rows()[0].pubkey, retained.pubkey); + assert.equal(rows()[0].action, "unavailable"); + assert.equal( + rows()[0].unavailableReason, + "Could not verify access. Retry to check again.", + ); + const assertBlocked = async () => { + assert.equal(mention.canSelectMention(retained), false); + await act(async () => { + picker.selectMentionSuggestion(retained); + picker.toggleAlwaysAddressAgent(retained); + }); + 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, []); + }; + await assertBlocked(); + let release; + state.heldDirectory = new Promise((resolve) => { + release = resolve; + }); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + await assertBlocked(); + state.heldDirectory = null; + await act(async () => release([rawAgent()])); + await settle(); + assert.equal(rows()[0].action, visible ? "mention" : "invite"); + assert.deepEqual( + mention.suggestions.map((row) => [ + row.pubkey, + row.personaId, + row.teamId, + row.displayName, + ]), + identities, + ); + assert.equal(rows()[0].pubkey, retained.pubkey); + assert.equal(mention.mentionSelectedIndex, 0); + assert.equal(mention.canSelectMention(retained), true); }); - await act(async () => mention.updateMentionQuery("@Remote", 7)); - await settle(); - const retained = rows()[0]; - assert.equal(retained.action, "mention"); - await act(async () => new Promise((resolve) => setTimeout(resolve, 5100))); - assert.equal(rows()[0].pubkey, retained.pubkey); - assert.equal(rows()[0].action, "unavailable"); - assert.equal( - rows()[0].unavailableReason, - "Could not verify access. Retry to check again.", - ); - const assertBlocked = async () => { +} + +for (const failure of ["denied", "lookup-failed"]) { + test(`installed relay-only nonmember preserves ${failure} reason and held Retry`, async () => { + await setup({ owner: OTHER, visible: false, directoryVisible: true }); + const action = "invite"; + const retained = rows()[0]; + assert.equal(retained.action, action); + const identities = mention.suggestions.map((row) => [ + row.pubkey, + row.personaId, + row.teamId, + row.displayName, + ]); + state.policy = failure === "denied" ? "owner-only" : "anyone"; + state.failDirectory = failure === "lookup-failed"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + assert.equal( + rows()[0].unavailableReason, + failure === "denied" + ? "This agent does not permit you to mention it here." + : "Could not verify access. Retry to check again.", + ); assert.equal(mention.canSelectMention(retained), false); - await act(async () => { - picker.selectMentionSuggestion(retained); - picker.toggleAlwaysAddressAgent(retained); + let release, reject; + state.heldDirectory = new Promise((resolve, fail) => { + release = resolve; + reject = fail; }); - for (const key of ["Tab", "Enter", " "]) { - let outcome; - await act(async () => { - outcome = mention.handleMentionKeyDown(keyboard(key)); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + assert.equal(rows()[0].onRetry, undefined); + assert.equal(mention.canSelectMention(retained), false); + if (failure === "lookup-failed") { + await act(async () => reject(new Error("Retry failed"))); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + assert.equal( + rows()[0].unavailableReason, + "Could not verify access. Retry to check again.", + ); + assert.equal(mention.canSelectMention(retained), false); + state.heldDirectory = new Promise((resolve) => { + release = resolve; }); - assert.equal(outcome.suggestion, undefined); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); } - assert.deepEqual(effects, []); - assert.deepEqual(mention.knownNames, []); - }; - await assertBlocked(); - let release; - state.heldDirectory = new Promise((resolve) => { - release = resolve; + state.policy = "anyone"; + state.failDirectory = false; + state.heldDirectory = null; + await act(async () => release([rawAgent()])); + await settle(); + assert.equal(rows()[0].action, action); + assert.equal(mention.canSelectMention(rows()[0]), true); + assert.deepEqual( + mention.suggestions.map((row) => [ + row.pubkey, + row.personaId, + row.teamId, + row.displayName, + ]), + identities, + ); + assert.equal(mention.mentionSelectedIndex, 0); + state.policy = "owner-only"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + await act(async () => mention.updateMentionQuery("@Remote", 7)); + await settle(); + assert.deepEqual( + rows(), + [], + "fresh discovery must still exclude denied nonmembers", + ); }); - await act(async () => rows()[0].onRetry()); - assert.equal(rows()[0].action, "checking"); - await assertBlocked(); - state.heldDirectory = null; - await act(async () => release([rawAgent()])); - await settle(); - assert.equal(rows()[0].action, "mention"); - assert.equal(rows()[0].pubkey, retained.pubkey); - assert.equal(mention.mentionSelectedIndex, 0); - assert.equal(mention.canSelectMention(retained), true); -}); +} diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 1f38a4fad0a..a52bb9c0da0 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -53,7 +53,16 @@ export type BuildMentionCandidatesInput = { * mention are dropped; identities appearing in several sources are coalesced * into a single entry that keeps the richest field from each. */ -export function buildMentionCandidates({ +export function buildMentionCandidates( + input: BuildMentionCandidatesInput, +): MentionCandidate[] { + return buildMentionCandidateProjection(input).candidates; +} + +/** Project shared live evidence separately from discovery eligibility. + * Evidence may update already-installed rows but must not discover new rows. + */ +export function buildMentionCandidateProjection({ activeAgentPubkeys, knownAgentPubkeys = new Set(), verificationFailed = false, @@ -80,7 +89,10 @@ export function buildMentionCandidates({ relayAgentNamesByPubkey, relayAgents, userSearchResults, -}: BuildMentionCandidatesInput): MentionCandidate[] { +}: BuildMentionCandidatesInput): { + candidates: MentionCandidate[]; + evidence: MentionCandidate[]; +} { const candidatesByPubkey = new Map(); const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -266,10 +278,10 @@ export function buildMentionCandidates({ hasEvidence && (mentionableAgentPubkeys.has(key) || memberPolicyAllows); const action = - isAgent && verificationFailed - ? "unavailable" - : isAgent && verificationPending - ? "checking" + isAgent && verificationPending + ? "checking" + : isAgent && verificationFailed + ? "unavailable" : !isAgent || allowed ? isMember ? "mention" @@ -311,7 +323,7 @@ export function buildMentionCandidates({ // 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( + const candidates = marked.filter( (candidate) => !candidate.isAgent || candidate.kind !== "identity" || @@ -320,4 +332,5 @@ export function buildMentionCandidates({ candidate.action === "mention-without-invite" || (candidate.isManagedAgent && candidate.action === "checking"), ); + return { candidates, evidence: marked }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1134f01024e..dd9a731348c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -63,9 +63,8 @@ import { buildTeamMentionCandidates, formatTeamMention, sameTeamMentionRecipients, - type MentionCandidate, } from "./mentionCandidates"; -import { buildMentionCandidates } from "./buildMentionCandidates"; +import { buildMentionCandidateProjection } from "./buildMentionCandidates"; const MENTION_SUGGESTION_LIMIT = 50; type UseMentionsOptions = { channelType?: ChannelType | null; @@ -291,9 +290,9 @@ export function useMentions( directoryError: !!relayAgentsQuery.error || !!managedAgentsQuery.error, retry: retryDirectory, }); - const mentionCandidates = React.useMemo( + const candidateProjection = React.useMemo( () => - buildMentionCandidates({ + buildMentionCandidateProjection({ activeAgentPubkeys, knownAgentPubkeys, verificationFailed, @@ -350,6 +349,8 @@ export function useMentions( relayAgentsQuery.data, ], ); + const { candidates: mentionCandidates, evidence: mentionCandidateEvidence } = + candidateProjection; const mentionCandidatesWithTeams = React.useMemo( () => markMentionCollisions([ @@ -489,11 +490,14 @@ export function useMentions( } = 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. + // without moving anyone's highlighted recipient. Identity evidence is read + // before discovery filtering, solely to update these already-installed rows. const suggestions = React.useMemo( () => snapshotSuggestions.map((row) => { - const live = mentionCandidatesWithTeams.find((candidate) => + const live = ( + row.pubkey ? mentionCandidateEvidence : mentionCandidatesWithTeams + ).find((candidate) => row.pubkey ? candidate.pubkey === row.pubkey : row.teamId @@ -511,7 +515,12 @@ export function useMentions( live?.action === "unavailable" || !live ? retryMention : undefined, }; }), - [snapshotSuggestions, mentionCandidatesWithTeams, retryMention], + [ + snapshotSuggestions, + mentionCandidateEvidence, + mentionCandidatesWithTeams, + retryMention, + ], ); const isMentionOpen = mentionQuery !== null; // Recheck against this render's exact-key evidence even if a child retained diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index 8a86ffded39..b7dab66c9d2 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -214,6 +214,16 @@ test("already visible checking and denied members remain disabled beside permitt name: "Retry access check for Verify pending", }), ).toBeVisible(); + // The installed Invite is a relay-only nonmember, not a roster row. + // Expiry must retain its exact DOM key and explain verification failure. + const retainedInvite = page.getByTestId(`mention-suggestion-${INVITE}`); + await expect(retainedInvite).toContainText("Unavailable"); + await expect(retainedInvite.locator("button").first()).toBeDisabled(); + await expect( + retainedInvite.getByRole("button", { name: /Retry access check/ }), + ).toHaveAccessibleDescription( + "Could not verify access. Retry to check again.", + ); const identities = await page .locator("[data-mention-suggestion-index]") .evaluateAll((rows) => diff --git a/docs/mention-editor.md b/docs/mention-editor.md index b7577b28e8e..980fa72647a 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -89,5 +89,9 @@ 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. +fresh evidence lookup, not a new chooser request: installed rows (including an +installed empty/error result) are not replaced or reordered. If initial results +have not yet installed, they still wait for the required discovery to settle. +Change the completion text or explicitly reopen to discover a new set of choices. +Live access is checked again at selection, including for rows whose display +snapshot originally permitted mentioning.