diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index d72085e90d9..9da1edcaf98 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,13 @@ async function setup(overrides = {}) { [["teams"], []], [["archivedIdentities"], { archived: [] }], ]) - client.setQueryData(key, data); + if ( + !( + (state.heldDirectory || state.coldDirectory) && + key[0] === "relay-agents" + ) + ) + client.setQueryData(key, data); const container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -281,6 +288,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 +579,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 +630,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 +680,291 @@ 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", "cold-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.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); + 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, + ); +}); + +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); + }); +} + +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); + let release, reject; + state.heldDirectory = new Promise((resolve, fail) => { + release = resolve; + reject = fail; + }); + 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; + }); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + } + 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", + ); + }); +} 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 a876cd704b9..a52bb9c0da0 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,17 @@ 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; + verificationPending?: boolean; + canInviteNonMembers?: boolean; + presenceFresh?: boolean; activePersonaById: ReadonlyMap; /** Already narrowed to `isActive` personas. */ activePersonas: readonly AgentPersona[]; @@ -54,8 +53,22 @@ 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, + verificationPending = false, + canInviteNonMembers = false, + presenceFresh = true, activePersonaById, activePersonas, canSearchGlobalUsers, @@ -76,26 +89,16 @@ 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); 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 +177,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 +239,98 @@ 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 && verificationPending + ? "checking" + : isAgent && verificationFailed + ? "unavailable" + : !isAgent || allowed + ? isMember + ? "mention" + : canInviteNonMembers + ? "invite" + : "mention-without-invite" + : ready && hasEvidence + ? "unavailable" + : verificationFailed + ? "unavailable" + : "checking"; + return { + ...candidate, + isAgent, + isMember, + ownerPubkey, + isOwned: + !!ownerPubkey && + !!currentPubkey && + normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), + action, + unavailableReason: + action === "unavailable" + ? !verificationFailed && 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]); + const candidates = marked.filter( + (candidate) => + !candidate.isAgent || + candidate.kind !== "identity" || + candidate.isMember || + candidate.action === "invite" || + candidate.action === "mention-without-invite" || + (candidate.isManagedAgent && candidate.action === "checking"), ); + return { candidates, evidence: marked }; } 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..864cb2b551b --- /dev/null +++ b/desktop/src/features/messages/lib/mentionPresentation.test.mjs @@ -0,0 +1,163 @@ +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]), + canInviteNonMembers: true, + }), + ); + 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..4f2172abb54 100644 --- a/desktop/src/features/messages/lib/mentionPresentation.ts +++ b/desktop/src/features/messages/lib/mentionPresentation.ts @@ -1,6 +1,13 @@ 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 }) { 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..b6da81f01cd --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionEvidence.ts @@ -0,0 +1,95 @@ +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 | 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; + 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(() => { + const token = ++generation.current.token; + setRetryState({ scope, pending: true, failed: false }); + setExpired(null); + setAttempt((value) => value + 1); + 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 && + 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..dd9a731348c 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -2,6 +2,8 @@ import { isMentionActionable, markMentionCollisions, } from "./mentionPresentation"; +import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; +import { useMentionEvidence } from "./useMentionEvidence"; import * as React from "react"; import { useManagedAgentsQuery, @@ -61,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; @@ -77,6 +78,7 @@ export function useMentions( profiles?: UserProfileLookup, options?: UseMentionsOptions, ) { + const canInviteNonMembers = useCanAddChannelMembers(channelId); const identityQuery = useIdentityQuery(); const currentPubkey = identityQuery.data?.pubkey ? normalizePubkey(identityQuery.data.pubkey) @@ -117,7 +119,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( @@ -222,10 +231,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 +257,48 @@ export function useMentions( }), [managedAgentPubkeys, members, profiles, relayAgentsQuery.data], ); - const mentionCandidates = React.useMemo( + 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, + membersQuery.refetch, + ]); + const { + knownAgentPubkeys, + verificationFailed, + verificationPending, + 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 candidateProjection = React.useMemo( () => - buildMentionCandidates({ + buildMentionCandidateProjection({ activeAgentPubkeys, + knownAgentPubkeys, + verificationFailed, + verificationPending, + canInviteNonMembers, + presenceFresh, activePersonaById, activePersonas, canSearchGlobalUsers, @@ -279,6 +322,11 @@ export function useMentions( }), [ activePersonaById, + knownAgentPubkeys, + verificationFailed, + verificationPending, + canInviteNonMembers, + presenceFresh, activeAgentPubkeys, activePersonas, userSearchResults, @@ -301,6 +349,8 @@ export function useMentions( relayAgentsQuery.data, ], ); + const { candidates: mentionCandidates, evidence: mentionCandidateEvidence } = + candidateProjection; const mentionCandidatesWithTeams = React.useMemo( () => markMentionCollisions([ @@ -359,6 +409,9 @@ export function useMentions( [searchableNames], ); searchableNamesLowerRef.current = searchableNamesLower; + const retryMention = React.useCallback(() => { + retryVerification(); + }, [retryVerification]); const matchingSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; @@ -369,8 +422,8 @@ export function useMentions( activePersonaIds, ) .slice(0, MENTION_SUGGESTION_LIMIT) - .map(({ candidate, label }) => - mapMentionCandidateToSuggestion({ + .map(({ candidate, label }) => ({ + ...mapMentionCandidateToSuggestion({ agentProvenanceReady: agentDirectoriesReady, candidate, label, @@ -379,10 +432,12 @@ export function useMentions( ownerProfiles: ownerProfilesQuery.data?.profiles, profiles, }), - ); + onRetry: candidate.action === "unavailable" ? retryMention : undefined, + })); }, [ activePersonaIds, agentDirectoriesReady, + retryMention, currentPubkey, mentionCandidatesWithTeams, mentionQuery, @@ -403,30 +458,70 @@ 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. Identity evidence is read + // before discovery filtering, solely to update these already-installed rows. + const suggestions = React.useMemo( + () => + snapshotSuggestions.map((row) => { + const live = ( + row.pubkey ? mentionCandidateEvidence : 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, + mentionCandidateEvidence, + 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 +774,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..ec1c4089212 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -563,3 +563,228 @@ 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); + await act(async () => 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(); +}); + +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 085d7221cbb..00d92a4ce0f 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 = { @@ -106,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); @@ -339,10 +349,8 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion, hasNameCollision, ); - const ownerLabel = - hasNameCollision && suggestion.agentProvenance - ? null - : suggestion.ownerLabel; + const reasonId = `${reasonIdPrefix}-${suggestionKey}-reason`; + const ownerLabel = suggestion.ownerLabel; const collisionNpub = hasNameCollision && suggestion.pubkey ? safeNpub(suggestion.pubkey) @@ -352,10 +360,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 +387,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ key={suggestionKey} > + {suggestion.action === "unavailable" && suggestion.onRetry ? ( + + ) : null} {canAlwaysAddress ? ( 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/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"); diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index 737b333088b..b7dab66c9d2 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,112 @@ 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(); + // 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) => + rows.map((row) => row.getAttribute("data-testid")), + ); + 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"); + 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..40fbe78a43e 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,71 +1877,122 @@ 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, { - 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(); - const input = page.getByTestId("message-input"); - await input.fill("@quinn"); - await expect( - page.getByRole("status").filter({ hasText: "No mentions found" }), - ).toBeVisible(); + { channelId: GENERAL_CHANNEL_ID, pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY }, + ); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + if (explicitPicker) { + // 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(); + } + 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 window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ - queryKey: ["relay-agents"], + await page.evaluate(async () => { + window.__BUZZ_E2E__.mock!.relayAgentListErrors = []; }); - }); - 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 ??= {}; - 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, @@ -2108,7 +2193,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 +2207,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, ); @@ -3186,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 @@ -3199,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 ({ @@ -3880,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); + }); +} 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); 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( diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 2af1784bc47..980fa72647a 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -86,3 +86,12 @@ 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 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.