From 4b0a3e8dbafadeea14a8155c3d0e269328400cf6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 11 Sep 2026 10:00:50 -0400 Subject: [PATCH] fix(desktop): consolidate fresh mention action admission and stable choices Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 2 + .../channels/freshMentionChatJourney.test.mjs | 1131 ++++++++++++++++ .../channels/membershipDirectorySync.test.mjs | 140 ++ .../channels/membershipDirectorySync.ts | 80 ++ .../channels/mentionAdmissionJourney.test.mjs | 1003 ++++++++++++++ .../channels/useLiveChannelUpdates.ts | 23 + ...ChannelUpdatesMembershipDirectory.test.mjs | 523 ++++++++ .../channels/useMembershipNotifications.ts | 2 + .../features/communities/useCommunityInit.ts | 4 + .../src/features/forum/ui/ForumComposer.tsx | 43 +- .../forum/ui/ForumComposerAutocompletes.tsx | 9 +- desktop/src/features/messages/hooks.ts | 2 + .../agentMentionAdmissionEvidence.test.mjs | 148 +++ .../messages/lib/agentMentionRevalidation.ts | 21 +- .../lib/buildMentionCandidates.test.mjs | 41 + .../messages/lib/buildMentionCandidates.ts | 157 ++- .../lib/flushMentionDebounce.test.mjs | 248 ---- .../messages/lib/flushMentionDebounce.ts | 111 -- .../messages/lib/mentionCandidates.test.mjs | 25 + .../messages/lib/mentionCandidates.ts | 33 +- .../lib/mentionHighlightExtension.test.mjs | 83 ++ .../messages/lib/mentionHighlightExtension.ts | 87 +- .../messages/lib/mentionMemberPubkeys.ts | 1 + .../messages/lib/mentionPresentation.test.mjs | 266 ++++ .../messages/lib/mentionPresentation.ts | 35 + .../features/messages/lib/mentionRanking.ts | 58 +- .../messages/lib/mentionSelectionHistory.ts | 44 + .../messages/lib/mentionSuggestionMapping.ts | 15 +- .../lib/scheduleComposerAutofocus.test.mjs | 129 ++ .../messages/lib/scheduleComposerAutofocus.ts | 75 ++ .../messages/lib/useComposerAutofocus.ts | 29 +- .../messages/lib/useMentionAdmission.ts | 112 ++ .../lib/useMentionAdmissionEditor.test.mjs | 213 +++ .../messages/lib/useMentionAdmissionEditor.ts | 52 + .../messages/lib/useMentionEvidence.ts | 95 ++ .../features/messages/lib/useMentionQuery.ts | 118 ++ .../messages/lib/useMentionSelection.ts | 81 +- .../src/features/messages/lib/useMentions.ts | 689 +++++----- .../messages/lib/useRichTextEditor.ts | 15 +- .../messages/ui/MentionAutocomplete.test.mjs | 225 ++++ .../messages/ui/MentionAutocomplete.tsx | 125 +- .../features/messages/ui/MessageComposer.tsx | 24 +- .../ui/MessageComposerAutocompletes.tsx | 3 +- .../messages/ui/NonMemberMentionDialog.tsx | 2 +- .../ui/composerAgentKeyboard.test.mjs | 11 +- .../ui/useAgentAddressLockPicker.test.mjs | 129 +- .../messages/ui/useAgentAddressLockPicker.ts | 239 ++-- .../messages/ui/useComposerScrollToBottom.ts | 14 + .../ui/useMentionSendFlow.authority.test.mjs | 21 + .../ui/useMentionSendFlow.helpers.test.mjs | 32 + .../messages/ui/useMentionSendFlow.helpers.ts | 12 +- .../ui/useMentionSendFlow.test-support.mjs | 10 +- .../messages/ui/useMentionSendFlow.ts | 15 +- desktop/src/testing/e2eBridge.ts | 16 + desktop/tests/e2e/channels.spec.ts | 9 + desktop/tests/e2e/community-rail.spec.ts | 110 ++ desktop/tests/e2e/composer-autofocus.spec.ts | 104 ++ .../e2e/composer-selection-formatting.spec.ts | 21 +- desktop/tests/e2e/mention-picker.spec.ts | 281 ++++ desktop/tests/e2e/mentions.spec.ts | 1162 +++++++++++++++-- .../e2e/message-feedback-snapshots.spec.ts | 2 + desktop/tests/e2e/onboarding.spec.ts | 16 +- .../e2e/persistent-agent-audience.spec.ts | 69 +- .../tests/e2e/remote-owned-mentions.spec.ts | 85 +- desktop/tests/e2e/team-mentions.spec.ts | 2 +- .../tests/e2e/workflow-local-controls.spec.ts | 43 +- desktop/tests/helpers/bridge.ts | 2 + desktop/tests/helpers/welcomeCollision.ts | 71 + docs/mention-editor.md | 79 ++ 69 files changed, 7729 insertions(+), 1148 deletions(-) create mode 100644 desktop/src/features/channels/freshMentionChatJourney.test.mjs create mode 100644 desktop/src/features/channels/membershipDirectorySync.test.mjs create mode 100644 desktop/src/features/channels/membershipDirectorySync.ts create mode 100644 desktop/src/features/channels/mentionAdmissionJourney.test.mjs create mode 100644 desktop/src/features/channels/useLiveChannelUpdatesMembershipDirectory.test.mjs create mode 100644 desktop/src/features/messages/lib/agentMentionAdmissionEvidence.test.mjs delete mode 100644 desktop/src/features/messages/lib/flushMentionDebounce.test.mjs delete mode 100644 desktop/src/features/messages/lib/flushMentionDebounce.ts create mode 100644 desktop/src/features/messages/lib/mentionPresentation.test.mjs create mode 100644 desktop/src/features/messages/lib/mentionPresentation.ts create mode 100644 desktop/src/features/messages/lib/mentionSelectionHistory.ts create mode 100644 desktop/src/features/messages/lib/scheduleComposerAutofocus.test.mjs create mode 100644 desktop/src/features/messages/lib/scheduleComposerAutofocus.ts create mode 100644 desktop/src/features/messages/lib/useMentionAdmission.ts create mode 100644 desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs create mode 100644 desktop/src/features/messages/lib/useMentionAdmissionEditor.ts create mode 100644 desktop/src/features/messages/lib/useMentionEvidence.ts create mode 100644 desktop/src/features/messages/lib/useMentionQuery.ts create mode 100644 desktop/src/features/messages/ui/useComposerScrollToBottom.ts create mode 100644 desktop/tests/e2e/composer-autofocus.spec.ts create mode 100644 desktop/tests/e2e/mention-picker.spec.ts create mode 100644 desktop/tests/helpers/welcomeCollision.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d530e0a1a2a..b385f64d0e4 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -74,10 +74,12 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/mention-picker.spec.ts", "**/remote-owned-mentions.spec.ts", "**/mention-spacing.spec.ts", "**/mention-recipients.spec.ts", "**/message-edit-focus.spec.ts", + "**/composer-autofocus.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/channels/freshMentionChatJourney.test.mjs b/desktop/src/features/channels/freshMentionChatJourney.test.mjs new file mode 100644 index 00000000000..ad2719d513d --- /dev/null +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -0,0 +1,1131 @@ +import { closeHistory } from "@tiptap/pm/history"; +import { + getMentionSelectionHistory, + resetMentionSelectionHistory, +} from "../messages/lib/mentionSelectionHistory.ts"; +// Production mention + CHAT picker + native Tiptap boundary; only IPC is fixture evidence. +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +// JSDOM has no layout; geometry is not an admission or input-dispatch fixture. +dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +dom.window.Range.prototype.getClientRects = () => []; +dom.window.Range.prototype.getBoundingClientRect = () => + new dom.window.DOMRect(); +// Radix tooltip focus-open runs document.dispatchEvent(new +// CustomEvent(TOOLTIP_OPEN)) against the ambient global; Node's CustomEvent is a +// foreign realm to this jsdom document, so install the jsdom constructor and +// restore Node's original in the after() teardown. +const originalCustomEvent = globalThis.CustomEvent; +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + CustomEvent: dom.window.CustomEvent, + localStorage: dom.window.localStorage, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + getComputedStyle: dom.window.getComputedStyle, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + MutationObserver: dom.window.MutationObserver, + IS_REACT_ACT_ENVIRONMENT: true, + self: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +dom.window.cancelAnimationFrame = clearTimeout; +globalThis.cancelAnimationFrame = clearTimeout; +const VIEWER = "a".repeat(64), + AGENT = "b".repeat(64), + OTHER = "c".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; +localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "test", + name: "Test", + relayUrl: "ws://test.invalid", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), +); +localStorage.setItem("buzz-active-community-id", "test"); +let state; +const channel = () => ({ + id: CHANNEL, + name: "fresh", + channel_type: "stream", + visibility: "open", + description: "", + is_member: true, + archived_at: null, + member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER], + member_count: state.visible ? 2 : 1, + participant_pubkeys: [], + participants: [], + last_message_at: null, + ttl_seconds: null, + ttl_deadline: null, +}); +const rawAgent = () => ({ + pubkey: AGENT, + owner_pubkey: state.owner, + name: "Remote Scout", + agent_type: "agent", + channels: [], + channel_ids: state.directoryVisible ? [CHANNEL] : [], + capabilities: [], + status: "offline", + respond_to: state.policy, + respond_to_allowlist: [], +}); +const invoke = async (command, args) => { + if (command.startsWith("plugin:event|")) return 0; + if (command === "search_users") { + if (state.pendingSearch?.[args.query]) + return state.pendingSearch[args.query]; + return { users: state.searchUsers ?? [], next_cursor: null }; + } + if (command === "get_identity") return { pubkey: VIEWER }; + if (command === "create_channel") return channel(); + if (command === "get_channels") + return { + channels: [channel()], + hash: String(state.visible), + last_messages: [], + }; + if (command === "get_channel_members" && state.heldRoster) + return state.heldRoster; + if (command === "get_channel_members") + return { + members: [ + { + pubkey: VIEWER, + role: "owner", + display_name: "Viewer", + is_agent: false, + }, + ...(state.visible + ? [ + { + pubkey: AGENT, + role: state.role, + display_name: "Remote Scout", + is_agent: true, + }, + ] + : []), + ], + }; + if (command === "add_channel_members") { + assert.equal(args.channelId, CHANNEL); + assert.equal(args.role, state.role); + state.accepted = true; + return state.addResult; + } + 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()]; + } + if (command === "revalidate_relay_agents") { + state.freshCalls = (state.freshCalls ?? 0) + 1; + assert.deepEqual(args.pubkeys, [AGENT]); + assert.equal(args.channelId, state.channelId); + if (state.fresh) return state.fresh; + if (state.failFresh) throw new Error("offline"); + return state.missingDirectory ? [] : [rawAgent()]; + } + if (["list_managed_agents", "list_personas", "list_teams"].includes(command)) + return []; + if (command === "get_users_batch") return { profiles: {}, missing: [] }; + if (command === "list_archived_identities") return { archived: [] }; + throw new Error(`Unexpected IPC: ${command}`); +}; +globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 }; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; +dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__; + +let React, + act, + createRoot, + QueryClient, + QueryClientProvider, + CommunitiesProvider; +let useMentions, useRichTextEditor, EditorContent, richText; +let root, client, mention, picker, focusMentionOptionsTrigger; +let useAgentAddressLockPicker, effects, MentionAutocomplete, TooltipProvider; +before(async () => { + ({ MentionAutocomplete, focusMentionOptionsTrigger } = await import( + "@/features/messages/ui/MentionAutocomplete.tsx" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); + ({ useAgentAddressLockPicker } = await import( + "@/features/messages/ui/useAgentAddressLockPicker.ts" + )); + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ useRichTextEditor } = await import( + "@/features/messages/lib/useRichTextEditor.ts" + )); + ({ EditorContent } = await import("@tiptap/react")); + ({ useMentions } = await import("@/features/messages/lib/useMentions.ts")); +}); +function Composer() { + const open = React.useRef(false); + const formRef = React.useRef(null); + const [keepPinned, setKeepPinned] = React.useState(true); + richText = useRichTextEditor({ + isAutocompleteOpen: open, + onSubmit: () => effects.push(["submit"]), + onUpdate: ({ text, cursor }) => mention?.updateMentionQuery(text, cursor), + onSelectionUpdate: ({ text, cursor }) => + mention?.updateMentionQuery(text, cursor), + }); + mention = useMentions(state.channelId, undefined, undefined, { + channelType: "stream", + getEditorSnapshot: richText.getPlainTextAndCursor, + }); + open.current = mention.isMentionOpen; + picker = useAgentAddressLockPicker({ + mentions: mention, + audience: { + pubkeys: state.locked, + addPubkey: (key) => effects.push(["pin", key]), + removePubkey: (key) => effects.push(["remove", key]), + }, + audienceScope: state.channelId, + richText, + applyAutocompleteEdit: (edit) => { + effects.push(["edit", edit]); + richText.replacePlainTextRange( + edit.replaceFromOffset, + edit.replaceToOffset, + edit.insertText, + undefined, + edit.preserveSelection, + edit.reassertMentionCaret, + ); + }, + onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]), + onAutoPinAgentMention: (row) => effects.push(["autoPin", row.pubkey]), + onImplicitPrefixInserted: (refs) => effects.push(["provenance", refs]), + onPulseAddressLock: () => effects.push(["pulse"]), + }); + return React.createElement( + "div", + { ref: formRef }, + React.createElement( + "div", + { + onKeyDown: (event) => { + // Only editor events reach this bridge, as in MessageComposer. + if ( + event.key === "Tab" && + event.shiftKey && + event.target === richText.editor.view.dom && + focusMentionOptionsTrigger(formRef.current) + ) { + event.preventDefault(); + return; + } + const result = mention.handleMentionKeyDown(event); + if (result.suggestion) + picker.selectMentionSuggestion(result.suggestion); + }, + }, + React.createElement(EditorContent, { editor: richText.editor }), + ), + React.createElement( + TooltipProvider, + null, + React.createElement(MentionAutocomplete, { + composerOwnsFocus: true, + keepMentionedAgentsPinned: keepPinned, + onKeepMentionedAgentsPinnedChange: state.withOptions + ? setKeepPinned + : undefined, + isOpen: mention.isMentionOpen, + isLoading: mention.isMentionLoading, + suggestions: mention.suggestions, + selectedIndex: mention.mentionSelectedIndex, + onSelect: picker.selectMentionSuggestion, + onToggleAlwaysAddressAgent: picker.toggleAlwaysAddressAgent, + alwaysAddressedAgentPubkeys: new Set(state.locked), + }), + ), + ); +} + +async function render(withComposer = true) { + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + withComposer ? React.createElement(Composer) : null, + ), + ), + ), + ); +} +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + // React Query notification batching may be enqueued by effects committed above. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} +const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT); +async function setup(overrides = {}) { + effects = []; + state = { + locked: [], + withOptions: true, + channelId: CHANNEL, + role: "bot", + owner: VIEWER, + policy: "anyone", + accepted: false, + visible: false, + directoryVisible: false, + directoryCalls: 0, + addResult: { added: [AGENT], errors: [] }, + ...overrides, + }; + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + for (const [key, data] of [ + [["identity"], { pubkey: VIEWER }], + [["channels"], [channel()]], + [["managed-agents"], []], + [["personas"], []], + [["teams"], []], + [["archivedIdentities"], { archived: [] }], + ]) + 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); + await render(); + await settle(); + await act(async () => { + richText.setContent("@"); + richText.editor.commands.setTextSelection(2); + mention.updateMentionQuery("@", 1); + }); + await settle(); + for ( + let i = 0; + i < mention.suggestions.length && + mention.suggestions[mention.mentionSelectedIndex]?.pubkey !== AGENT; + i++ + ) { + await act(async () => choose("ArrowDown")); + } + assert.equal( + mention.suggestions[mention.mentionSelectedIndex]?.pubkey, + AGENT, + ); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + resetMentionSelectionHistory(); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => { + globalThis.CustomEvent = originalCustomEvent; + dom.window.close(); +}); + +function unchanged() { + assert.equal(richText.getPlainTextAndCursor().text, "@"); + assert.deepEqual(mention.getDraftMentionRefs("@Remote Scout "), []); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.agentKnownNames, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.deepEqual(effects, []); +} +function choose(mode) { + if (mode === "pin") { + const toggle = document.querySelector( + `[data-testid="mention-always-address-${AGENT}"]`, + ); + assert.ok(toggle, "production pin Toggle"); + toggle.click(); + } else if (mode === "pointer") picker.selectMentionSuggestion(rows()[0]); + else { + const event = new dom.window.KeyboardEvent("keydown", { + key: mode, + bubbles: true, + cancelable: true, + }); + richText.editor.view.dom.dispatchEvent(event); + assert.equal(event.defaultPrevented, true); + } +} +for (const mode of ["pointer", "Enter", "Tab", "pin"]) { + for (const outcome of ["allow", "revoke", "failure"]) { + test(`${mode}: fresh ${outcome} is atomic without discovery refresh`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(mode)); + assert.match(picker.announcement, /Checking/); + unchanged(); + assert.equal(state.freshCalls, 1); + if (outcome === "revoke") state.policy = "owner-only"; + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + if (outcome === "allow") { + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); + assert.equal( + effects.filter(([kind]) => kind === "edit").length, + mode === "pin" ? 2 : 1, + ); + assert.equal( + effects.filter( + ([kind]) => kind === (mode === "pin" ? "promote" : "autoPin"), + ).length, + 1, + ); + assert.equal( + mention.getDraftMentionRefs("@Remote Scout ")[0].pubkey, + AGENT, + ); + assert.equal( + getMentionSelectionHistory(VIEWER, CHANNEL).length, + mode === "pin" ? 0 : 1, + ); + } else { + unchanged(); + assert.match( + picker.announcement, + outcome === "revoke" ? /Access changed/ : /Could not check/, + ); + } + }); + } +} +for (const change of [ + "edit-undo", + "caret-return", + "scope-return", + "unmount", + "Escape", + "ArrowDown", +]) { + test(`late allow after ${change} cannot commit`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("Enter")); + unchanged(); + if (change === "scope-return") { + state.channelId = "other"; + await render(); + state.channelId = CHANNEL; + await render(); + } else if (change === "unmount") await render(false); + else + await act(async () => { + if (change === "edit-undo") { + richText.editor.view.dispatch(closeHistory(richText.editor.state.tr)); + richText.editor.commands.insertContent("x"); + richText.editor.commands.undo(); + assert.equal(richText.getPlainTextAndCursor().text, "@"); + } else if (change === "caret-return") { + richText.editor.commands.setTextSelection(1); + richText.editor.commands.setTextSelection(2); + } else choose(change); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (change !== "unmount") + assert.doesNotMatch(picker.announcement, /Checking/); + }); +} + +// JSDOM does not perform native Tab movement or keyboard-generated clicks. +// Dispatch the key, then enact that browser default explicitly; the focus and +// production admission/picker/editor effects are real, not mocked cancellation. +for (const mode of ["Enter", "pin"]) { + test(`navigation: editor ShiftTab abandons ${mode} even after return`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => { + richText.editor.view.dom.focus(); + choose(mode); + }); + unchanged(); + await act(async () => { + richText.editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + const trigger = document.querySelector("[data-mention-options-trigger]"); + assert.ok(trigger, "chat Options trigger exists"); + assert.equal( + document.activeElement === trigger, + true, + "ShiftTab focuses Options", + ); + richText.editor.view.dom.focus(); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + }); +} +for (const shiftKey of [false, true]) { + for (const depart of [false, true]) { + test(`navigation: focused pin ${shiftKey ? "ShiftTab" : "Tab"} depart=${depart}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + const toggle = document.querySelector( + `[data-testid="mention-always-address-${AGENT}"]`, + ); + assert.ok(toggle, "production pin Toggle exists"); + await act(async () => { + toggle.focus(); + assert.equal( + document.activeElement === toggle, + true, + "keyboard pin owns focus", + ); + const key = shiftKey ? " " : "Enter"; + const event = new dom.window.KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + toggle.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "overlay activation stays native", + ); + assert.equal( + state.freshCalls ?? 0, + 0, + "pin keydown must not select in editor", + ); + const release = new dom.window.KeyboardEvent("keyup", { + key, + bubbles: true, + cancelable: true, + }); + // JSDOM lacks native activation: Enter clicks on keydown, Space on keyup. + if (key === " ") toggle.dispatchEvent(release); + if (!event.defaultPrevented && !release.defaultPrevented) + toggle.click(); + }); + unchanged(); + assert.equal(state.freshCalls, 1); + if (depart) + await act(async () => { + const event = new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey, + bubbles: true, + cancelable: true, + }); + toggle.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "overlay Tab stays native", + ); + const outside = document.createElement("button"); + document.body.append(outside); + if (!event.defaultPrevented) outside.focus(); + assert.equal( + document.activeElement === outside, + true, + "Tab departs pin", + ); + toggle.focus(); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + if (depart) { + unchanged(); + } else { + assert.deepEqual( + effects.filter(([kind]) => kind === "promote"), + [["promote", AGENT]], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "autoPin"), + [], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "provenance"), + [["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]]], + ); + assert.equal(effects.filter(([kind]) => kind === "edit").length, 2); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); + assert.equal( + mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey, + AGENT, + ); + // MentionHighlightExtension decorates literal @labels from either path; + // .mention-chip is not selection provenance. The pin witnesses above + // distinguish admission paths; verify the prefix is plain document text + // (useRichTextEditor.replacePlainTextRange), not an embedded mention node. + assert.deepEqual(richText.editor.getJSON(), { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "@Remote Scout " }], + }, + ], + }); + } + }); + } +} + +for (const departure of ["window", "no-Options native fallback"]) { + test(`navigation: ${departure} abandons pending selection after return`, async () => { + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + withOptions: departure !== "no-Options native fallback", + }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => { + richText.editor.view.dom.focus(); + choose("Enter"); + }); + unchanged(); + assert.equal(state.freshCalls, 1); + await act(async () => { + const editor = richText.editor.view.dom; + if (departure === "window") { + // Window departure can retain activeElement; dispatch only that boundary. + dom.window.dispatchEvent(new dom.window.Event("blur")); + assert.equal( + document.activeElement === editor, + true, + "window blur retains editor identity", + ); + dom.window.dispatchEvent(new dom.window.Event("focus")); + } else { + assert.equal( + document.querySelector("[data-mention-options-trigger]") === null, + true, + ); + const event = new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "no Options leaves ShiftTab native", + ); + // Explicit native Tab default emulation, not a browser tab-order claim. + const outside = document.createElement("button"); + document.body.append(outside); + if (!event.defaultPrevented) outside.focus(); + assert.equal( + document.activeElement === outside, + true, + "native fallback departs editor", + ); + editor.focus(); + } + }); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + }); +} + +test("literal Space remains native, normal Enter submits outside chooser", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const event = new dom.window.KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + await act(async () => richText.editor.view.dom.dispatchEvent(event)); + assert.equal(event.defaultPrevented, false); + assert.equal(state.freshCalls ?? 0, 0); + await act(async () => mention.cancelMentionAutocomplete()); + await act(async () => choose("Enter")); + assert.deepEqual(effects, [["submit"]]); +}); +test("repeat Enter while pending consumes input without another lookup", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("Enter")); + await act(async () => choose("Enter")); + unchanged(); + assert.equal(state.freshCalls, 1); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.equal(effects.filter(([kind]) => kind === "edit").length, 1); +}); + +test("exact Space enters the same fresh operation", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => { + richText.setContent("@Remote Scout"); + richText.editor.commands.setTextSelection(14); + mention.updateMentionQuery("@Remote Scout", 13); + }); + await settle(); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(" ")); + assert.match(picker.announcement, /Checking/); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout"); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.equal(state.freshCalls, 1); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); +}); +test("late lookup rejection after native input cancellation is silent", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let reject; + state.fresh = new Promise((_resolve, r) => { + reject = r; + }); + await act(async () => choose("Enter")); + await act(async () => + richText.editor.view.dom.dispatchEvent( + new dom.window.InputEvent("beforeinput", { + bubbles: true, + inputType: "insertText", + data: "x", + }), + ), + ); + await act(async () => reject(new Error("late offline"))); + await settle(); + unchanged(); + assert.equal(picker.announcement, ""); +}); + +for (const change of ["edit-undo", "unpin", "failure-retry"]) { + test(`pin pending: ${change}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("pin")); + unchanged(); + if (change === "edit-undo") { + await act(async () => { + richText.editor.view.dispatch(closeHistory(richText.editor.state.tr)); + richText.editor.commands.insertContent("x"); + richText.editor.commands.undo(); + assert.equal(richText.getPlainTextAndCursor().text, "@"); + }); + } else if (change === "unpin") { + state.locked = [AGENT]; + await render(); + await act(async () => picker.toggleAlwaysAddressAgent(rows()[0])); + assert.ok(effects.some(([kind]) => kind === "remove")); + effects = []; + } + await act(async () => + resolve( + change === "failure-retry" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (change === "failure-retry") { + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choose("pin")); + await settle(); + assert.equal(state.freshCalls, 2); + assert.ok(effects.some(([kind]) => kind === "promote")); + } + }); +} + +async function setupForum(text = "@") { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const { ForumComposer } = await import( + "@/features/forum/ui/ForumComposer.tsx" + ); + const { createRouter, createRootRoute, createMemoryHistory, RouterProvider } = + await import("@tanstack/react-router"); + const { TooltipProvider } = await import("@/shared/ui/tooltip.tsx"); + const route = createRootRoute({ + component: () => + React.createElement( + TooltipProvider, + null, + React.createElement(ForumComposer, { + channelId: CHANNEL, + channelType: "forum", + onSubmit: async (...args) => effects.push(["submit", ...args]), + }), + ), + }); + const router = createRouter({ + routeTree: route, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(RouterProvider, { router }), + ), + ), + ), + ); + await settle(); + const element = document.querySelector(".tiptap"); + const editor = element.editor; + assert.ok(editor, "actual standalone ForumComposer Tiptap editor"); + await act(async () => { + editor.commands.setContent(text); + editor.commands.setTextSelection(text.length + 1); + editor.view.focus(); + }); + await settle(); + return editor; +} +for (const mode of ["pointer", "Enter", "Tab", " "]) + for (const outcome of ["allow", "revoke", "failure", "cancel"]) { + test(`standalone forum production component ${mode}: ${outcome}`, async () => { + const draft = mode === " " ? "@Remote Scout" : "@"; + const editor = await setupForum(draft); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + const row = document.querySelector( + `[data-testid="mention-suggestion-${AGENT}"]`, + ); + assert.ok(row, "displayed exact agent row"); + await act(async () => { + if (mode === "pointer") + row.querySelector("button").dispatchEvent( + new dom.window.MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }), + ); + else { + for (let i = 0; i < Number(row.dataset.mentionSuggestionIndex); i++) + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ); + } + }); + if (mode !== "pointer") + await act(async () => + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: mode, + bubbles: true, + cancelable: true, + }), + ), + ); + assert.equal(editor.getText(), draft); + assert.match(document.querySelector("output").textContent, /Checking/); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (outcome === "revoke") state.policy = "owner-only"; + if (outcome === "cancel") + await act(async () => { + editor.commands.setTextSelection(1); + editor.commands.setTextSelection(2); + }); + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.equal( + editor.getText(), + outcome === "allow" ? "@Remote Scout " : draft, + ); + assert.equal( + getMentionSelectionHistory(VIEWER, CHANNEL).length, + outcome === "allow" ? 1 : 0, + ); + assert.deepEqual(effects, []); + if (outcome === "revoke" || outcome === "failure") { + assert.match( + document.querySelector("output").textContent, + outcome === "revoke" ? /Access changed/ : /Could not check/, + ); + state.policy = "anyone"; + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => + row.querySelector("button").dispatchEvent( + new dom.window.MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(editor.getText(), "@Remote Scout "); + assert.equal(state.freshCalls, 2); + } + if (outcome === "allow") { + state.policy = "owner-only"; + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => + document.querySelector("form").dispatchEvent( + new dom.window.Event("submit", { + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(editor.getText(), "@Remote Scout "); + assert.equal( + state.freshCalls, + 2, + "publication must check authority independently", + ); + assert.deepEqual(effects, []); + } + }); + } + +test("closed-picker pin authority timeout is retryable and late allow cannot mutate", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => mention.cancelMentionAutocomplete()); + const choosePin = () => + picker.toggleAlwaysAddressAgent(mention.getDefaultAgentSuggestion()); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choosePin()); + unchanged(); + await act(async () => new Promise((r) => setTimeout(r, 15100))); + assert.match(picker.announcement, /Could not check/); + unchanged(); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choosePin()); + await settle(); + assert.ok(effects.some(([kind]) => kind === "promote")); +}); + +for (const outcome of ["allow", "revoke", "failure", "cancel"]) { + test(`closed-picker default pin: ${outcome}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => { + richText.setContent(""); + mention.cancelMentionAutocomplete(); + }); + await settle(); + const row = mention.getDefaultAgentSuggestion(); + assert.equal(row.pubkey, AGENT); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => picker.toggleAlwaysAddressAgent(row)); + assert.match(picker.announcement, /Checking/); + assert.deepEqual(effects, []); + if (outcome === "revoke") state.policy = "owner-only"; + if (outcome === "cancel") + await act(async () => + richText.editor.view.dom.dispatchEvent( + new dom.window.InputEvent("beforeinput", { bubbles: true }), + ), + ); + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.equal( + richText.getPlainTextAndCursor().text, + outcome === "allow" ? "@Remote Scout " : "", + ); + if (outcome !== "allow") { + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); + } + }); +} + +test("standalone forum literal Space and Enter outside chooser retain native dispatch", async () => { + const editor = await setupForum("@Rem"); + const space = new dom.window.KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + await act(async () => editor.view.dom.dispatchEvent(space)); + assert.equal(space.defaultPrevented, false); + assert.equal(state.freshCalls, undefined); + await act(async () => { + editor.commands.setContent("ordinary text"); + editor.commands.setTextSelection(14); + }); + await settle(); + await act(async () => + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(effects.filter(([kind]) => kind === "submit").length, 1); +}); + +for (const mode of ["Enter", "pin"]) { + test(`${mode}: directory failure and Retry cannot revive older fresh admission`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const identityOrder = mention.suggestions.map((row) => row.pubkey); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(mode)); + unchanged(); + state.failDirectory = true; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + await act(async () => rows()[0].onRetry()); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + unchanged(); + state.failDirectory = false; + let releaseDirectory; + state.heldDirectory = new Promise((r) => { + releaseDirectory = r; + }); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + unchanged(); + state.heldDirectory = null; + await act(async () => releaseDirectory([rawAgent()])); + await settle(); + assert.equal(rows()[0].action, "mention"); + assert.deepEqual( + mention.suggestions.map((row) => row.pubkey), + identityOrder, + ); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choose(mode)); + await settle(); + assert.equal(state.freshCalls, 2); + assert.ok( + effects.some(([kind]) => kind === (mode === "pin" ? "promote" : "edit")), + ); + }); +} diff --git a/desktop/src/features/channels/membershipDirectorySync.test.mjs b/desktop/src/features/channels/membershipDirectorySync.test.mjs new file mode 100644 index 00000000000..105db44a8aa --- /dev/null +++ b/desktop/src/features/channels/membershipDirectorySync.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { + refreshDirectoryAfterMembershipChange as refresh, + resetMembershipDirectorySync, +} from "./membershipDirectorySync.ts"; + +const KEY = ["relay-agents"]; +const clients = []; +const disposers = []; +const settle = () => new Promise((resolve) => setTimeout(resolve, 250)); +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { resolve, promise }; +} +function client() { + const value = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + clients.push(value); + return value; +} +function observe(value, queryFn, initialData = []) { + if (initialData !== undefined) value.setQueryData(KEY, initialData); + const observer = new QueryObserver(value, { + queryKey: KEY, + queryFn, + staleTime: Infinity, + }); + disposers.push(observer.subscribe(() => {})); + return observer; +} +afterEach(() => { + resetMembershipDirectorySync(); + for (const dispose of disposers.splice(0)) dispose(); + for (const value of clients.splice(0)) value.clear(); +}); + +test("membership burst fetches once; replay of the same event does not rebuild the directory", async () => { + const value = client(); + let calls = 0; + observe(value, async () => { + calls += 1; + return ["current"]; + }); + for (let index = 0; index < 50; index += 1) refresh(value, `event-${index}`); + assert.equal(value.getQueryState(KEY).isInvalidated, true); + assert.equal(calls, 0); + await settle(); + assert.equal(calls, 1); + for (let index = 0; index < 50; index += 1) refresh(value, `event-${index}`); + await settle(); + assert.equal(calls, 1); +}); + +test("a membership change cancels an older read; late positive data cannot overwrite removal", async () => { + const value = client(); + const old = deferred(); + let calls = 0; + observe(value, async () => { + calls += 1; + return calls === 1 ? old.promise : []; + }, ["removed-key"]); + void value.refetchQueries({ queryKey: KEY }); + assert.equal(calls, 1); + refresh(value, "removed"); + await settle(); + assert.equal(calls, 2); + assert.deepEqual(value.getQueryData(KEY), []); + old.resolve(["removed-key"]); + await settle(); + assert.deepEqual(value.getQueryData(KEY), []); + assert.equal(calls, 2); +}); + +test("a cold pending read is also replaced, rather than swallowing the post-write refresh", async () => { + const value = client(); + const old = deferred(); + let calls = 0; + const observer = new QueryObserver(value, { + queryKey: KEY, + queryFn: () => + ++calls === 1 ? old.promise : Promise.resolve(["added-key"]), + }); + disposers.push(observer.subscribe(() => {})); + refresh(value, "added"); + await settle(); + assert.equal(calls, 2); + old.resolve([]); + await settle(); + assert.deepEqual(value.getQueryData(KEY), ["added-key"]); +}); + +test("inactive directory is marked stale without starting a relay-wide read", async () => { + const value = client(); + value.setQueryData(KEY, ["old"]); + refresh(value); + await settle(); + assert.equal(value.getQueryState(KEY).isInvalidated, true); + assert.equal(value.getQueryState(KEY).fetchStatus, "idle"); +}); + +test("failed refresh ends in error and does not schedule a self-sustaining retry loop", async () => { + const value = client(); + let calls = 0; + observe(value, async () => { + calls += 1; + throw new Error("unavailable"); + }); + refresh(value); + await settle(); + assert.equal(value.getQueryState(KEY).status, "error"); + await settle(); + assert.equal(calls, 1); +}); + +test("community reset cancels queued work and event deduplication is client-scoped", async () => { + const first = client(); + const second = client(); + let firstCalls = 0; + let secondCalls = 0; + observe(first, async () => { + firstCalls += 1; + return []; + }); + observe(second, async () => { + secondCalls += 1; + return []; + }); + refresh(first, "shared-event-id"); + resetMembershipDirectorySync(); + refresh(second, "shared-event-id"); + await settle(); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 1); +}); diff --git a/desktop/src/features/channels/membershipDirectorySync.ts b/desktop/src/features/channels/membershipDirectorySync.ts new file mode 100644 index 00000000000..4c962e399e9 --- /dev/null +++ b/desktop/src/features/channels/membershipDirectorySync.ts @@ -0,0 +1,80 @@ +import type { QueryClient } from "@tanstack/react-query"; + +const directoryQueryKey = ["relay-agents"] as const; +const COALESCE_MS = 200; +const MAX_EVENT_IDS = 256; + +type PendingRefresh = { + eventIds: Set; + timer?: ReturnType; +}; + +let refreshes = new WeakMap(); +const scheduled = new Set(); +let generation = 0; + +/** Cancel queued work when the active community or signing identity changes. */ +export function resetMembershipDirectorySync(): void { + generation += 1; + for (const refresh of scheduled) clearTimeout(refresh.timer); + scheduled.clear(); + refreshes = new WeakMap(); +} + +/** + * Membership changes invalidate the shared directory's channel projection. + * Only accepted writes and membership events call this: local agent-store + * rebuilds and policy replay must not create a directory refresh loop. + * + * Mark stale immediately, then coalesce bursts in a fixed window (not a sliding + * debounce that could starve under load). Cancel even a cold in-flight read at + * flush time: it may have started before the membership write. This refresh + * supplies evidence, never permission or optimistic directory entries. + */ +export function refreshDirectoryAfterMembershipChange( + queryClient: QueryClient, + eventId?: string, +): void { + let refresh = refreshes.get(queryClient); + if (!refresh) { + refresh = { eventIds: new Set() }; + refreshes.set(queryClient, refresh); + } + if (eventId) { + if (refresh.eventIds.has(eventId)) return; + refresh.eventIds.add(eventId); + if (refresh.eventIds.size > MAX_EVENT_IDS) { + const oldest = refresh.eventIds.values().next().value; + if (oldest !== undefined) refresh.eventIds.delete(oldest); + } + } + // Cancellation updates Query state synchronously, including cold requests. + // Do it before marking stale so an old completion cannot look like new proof. + void queryClient.cancelQueries({ queryKey: directoryQueryKey }); + void queryClient.invalidateQueries({ + queryKey: directoryQueryKey, + refetchType: "none", + }); + if (refresh.timer !== undefined) return; + + const pending = refresh; + const scheduledGeneration = generation; + scheduled.add(pending); + pending.timer = setTimeout(() => { + pending.timer = undefined; + scheduled.delete(pending); + const state = queryClient.getQueryState(directoryQueryKey); + // An observer mounting during this window may already have fetched the + // invalidated query. Do not immediately duplicate that successful read. + if ( + state?.status === "success" && + !state.isInvalidated && + state.fetchStatus === "idle" + ) + return; + void queryClient.cancelQueries({ queryKey: directoryQueryKey }).then(() => { + if (scheduledGeneration !== generation) return; + return queryClient.invalidateQueries({ queryKey: directoryQueryKey }); + }); + }, COALESCE_MS); +} diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs new file mode 100644 index 00000000000..805913b4afd --- /dev/null +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -0,0 +1,1003 @@ +import { + getMentionSelectionHistory, + resetMentionSelectionHistory, +} from "../messages/lib/mentionSelectionHistory.ts"; +// Admission against existing root query evidence, without membership freshness production. +// Real mention and picker hooks; Tauri policy/classification are fixture evidence. +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + HTMLElement: dom.window.HTMLElement, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + MutationObserver: dom.window.MutationObserver, + IS_REACT_ACT_ENVIRONMENT: true, + self: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +const VIEWER = "a".repeat(64), + AGENT = "b".repeat(64), + OTHER = "c".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; +localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "test", + name: "Test", + relayUrl: "ws://test.invalid", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), +); +localStorage.setItem("buzz-active-community-id", "test"); +let state; +const channel = () => ({ + id: CHANNEL, + name: "fresh", + channel_type: "stream", + visibility: "open", + description: "", + is_member: true, + archived_at: null, + member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER], + member_count: state.visible ? 2 : 1, + participant_pubkeys: [], + participants: [], + last_message_at: null, + ttl_seconds: null, + ttl_deadline: null, +}); +const rawAgent = () => ({ + pubkey: AGENT, + owner_pubkey: state.owner, + name: "Remote Scout", + agent_type: "agent", + channels: [], + channel_ids: state.directoryVisible ? [CHANNEL] : [], + capabilities: [], + status: "offline", + respond_to: state.policy, + respond_to_allowlist: [], +}); +const invoke = async (command, args) => { + if (command.startsWith("plugin:event|")) return 0; + if (command === "search_users") { + if (state.pendingSearch?.[args.query]) + return state.pendingSearch[args.query]; + return { users: state.searchUsers ?? [], next_cursor: null }; + } + if (command === "get_identity") return { pubkey: VIEWER }; + if (command === "create_channel") return channel(); + if (command === "get_channels") + return { + channels: [channel()], + hash: String(state.visible), + last_messages: [], + }; + if (command === "get_channel_members" && state.heldRoster) + return state.heldRoster; + if (command === "get_channel_members") + return { + members: [ + { + pubkey: VIEWER, + role: "owner", + display_name: "Viewer", + is_agent: false, + }, + ...(state.visible + ? [ + { + pubkey: AGENT, + role: state.role, + display_name: "Remote Scout", + is_agent: true, + }, + ] + : []), + ], + }; + if (command === "add_channel_members") { + assert.equal(args.channelId, CHANNEL); + assert.equal(args.role, state.role); + state.accepted = true; + return state.addResult; + } + 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()]; + } + if (command === "revalidate_relay_agents") + return state.missingDirectory ? [] : [rawAgent()]; + if (["list_managed_agents", "list_personas", "list_teams"].includes(command)) + return []; + if (command === "get_users_batch") return { profiles: {}, missing: [] }; + if (command === "list_archived_identities") return { archived: [] }; + throw new Error(`Unexpected IPC: ${command}`); +}; +globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 }; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; +dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__; + +let React, + act, + createRoot, + QueryClient, + QueryClientProvider, + CommunitiesProvider; +let useMentions; +let root, client, mention, picker; +let useAgentAddressLockPicker, effects; +before(async () => { + ({ useAgentAddressLockPicker } = await import( + "@/features/messages/ui/useAgentAddressLockPicker.ts" + )); + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ useMentions } = await import("@/features/messages/lib/useMentions.ts")); +}); +function Composer() { + mention = useMentions(state.channelId, undefined, undefined, { + channelType: state.channelType ?? "stream", + }); + picker = useAgentAddressLockPicker({ + mentions: mention, + audience: { + pubkeys: state.locked, + addPubkey: (key) => effects.push(["pin", key]), + removePubkey: (key) => effects.push(["remove", key]), + }, + audienceScope: state.channelId, + richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) }, + applyAutocompleteEdit: (edit) => effects.push(["edit", edit]), + onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]), + onPulseAddressLock: () => {}, + }); + return null; +} +async function render(withComposer = true) { + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + withComposer ? React.createElement(Composer) : null, + ), + ), + ), + ); +} +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + // React Query notification batching may be enqueued by effects committed above. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} +const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT); +async function setup(overrides = {}) { + effects = []; + state = { + locked: [], + channelId: CHANNEL, + role: "bot", + owner: VIEWER, + policy: "anyone", + accepted: false, + visible: false, + directoryVisible: false, + directoryCalls: 0, + addResult: { added: [AGENT], errors: [] }, + ...overrides, + }; + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + for (const [key, data] of [ + [["identity"], { pubkey: VIEWER }], + [["channels"], [channel()]], + [["managed-agents"], []], + [["personas"], []], + [["teams"], []], + [["archivedIdentities"], { archived: [] }], + ]) + 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); + await render(); + await settle(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settle(); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + resetMentionSelectionHistory(); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => dom.window.close()); + +for (const change of [ + "policy-denied", + "late-error", + "directory-removed", + "member-removed", +]) { + test(`a retained callback cannot bind after ${change}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const staleRow = rows()[0]; + const staleInsert = captureInsert(mention.selectMention); + assert.equal(staleRow.isAgent, true); + assert.equal(mention.canSelectMention(staleRow), true); + if (change === "policy-denied") state.policy = "owner-only"; + if (change === "late-error") state.failDirectory = true; + if (change.endsWith("removed")) state.missingDirectory = true; + if (change === "member-removed") { + state.visible = false; + await act(async () => + client.invalidateQueries({ + queryKey: ["channels", CHANNEL, "members"], + }), + ); + } + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + let edit; + await act(async () => { + edit = staleInsert(staleRow, 1); + }); + assert.equal( + edit.insertText, + "", + "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", + ); + }); +} + +test("only an exact current target can be selected", async () => { + await setup({ visible: true, directoryVisible: true }); + assert.equal(mention.canSelectMention(rows()[0]), true); + for (const target of [ + { displayName: "Remote Scout" }, + { displayName: "Remote Scout", pubkey: OTHER }, + ]) { + assert.equal(mention.canSelectMention(target), false); + let edit; + await act(async () => { + edit = captureInsert(mention.selectMention)(target, 1); + }); + assert.equal(edit.insertText, ""); + } + assert.deepEqual(mention.knownNames, []); +}); + +// These exercise the real sibling picker + mention hook, not an admission stub. +test("retained explicit pin rejects latest policy denial without draft effects", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + state.policy = "owner-only"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows().length, 1, "denial does not move the displayed row"); + await act(async () => oldPin(row)); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.deepEqual(mention.knownNames, []); +}); + +for (const returnToOrigin of [false, true]) { + test(`retained pin and insertion reject another scope visit (return=${returnToOrigin})`, async () => { + await setup({ visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + const oldInsert = captureInsert(mention.selectMention); + const oldSelect = picker.selectMentionSuggestion; + state.channelId = "22222222-2222-4222-8222-222222222222"; + await render(); + if (returnToOrigin) { + state.channelId = CHANNEL; + await render(); + } + let edit; + await act(async () => { + oldPin(row); + oldSelect(row); + edit = oldInsert(row, 1); + }); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.equal(edit.insertText, ""); + assert.deepEqual(mention.knownNames, []); + }); +} + +test("latest locked state permits removal after denial, including a retained toggle", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + state.locked = [AGENT]; + state.policy = "owner-only"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(mention.canSelectMention(row), false); + await act(async () => oldPin(row)); + assert.ok( + effects.some(([effect, key]) => effect === "remove" && key === AGENT), + ); + assert.ok( + effects.every( + ([effect, edit]) => + effect === "remove" || (effect === "edit" && edit.insertText === ""), + ), + ); + assert.deepEqual(mention.knownNames, []); +}); + +test("retained team cannot bind a removed exact member", async () => { + await setup({ visible: true, directoryVisible: true }); + const persona = { + id: "review-scout", + displayName: "Remote Scout", + isActive: true, + }; + const team = { + id: "team-review", + name: "Review Team", + isBuiltin: false, + personaIds: [persona.id], + }; + await act(async () => { + client.setQueryData(["personas"], [persona]); + client.setQueryData( + ["managed-agents"], + [ + { + pubkey: AGENT, + name: "Remote Scout", + personaId: persona.id, + status: "running", + }, + ], + ); + client.setQueryData(["teams"], [team]); + }); + await settle(); + await act(async () => mention.openMentionPicker(1)); + const row = mention.suggestions.find((s) => s.kind === "team"); + assert.ok(row, JSON.stringify(mention.suggestions)); + assert.equal(row.teamMembers[0].pubkey, AGENT); + const old = captureInsert(mention.selectMention); + state.missingDirectory = true; + await act(async () => client.setQueryData(["managed-agents"], [])); + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal( + mention.suggestions.some((s) => s.pubkey === AGENT), + true, + ); + assert.ok(mention.suggestions.find((s) => s.kind === "team")); + let edit; + await act(async () => { + edit = old(row, 1); + }); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []); + assert.equal( + edit.insertText, + "", + "removed exact team member must not establish intent", + ); +}); + +test("duplicate team members cannot mask a recipient set change", async () => { + await setup({ visible: true, directoryVisible: true }); + const personas = ["one", "two"].map((id) => ({ + id, + displayName: id, + isActive: true, + })); + const team = { + id: "duplicates", + name: "Duplicates", + isBuiltin: false, + personaIds: ["one", "one"], + }; + await act(async () => { + client.setQueryData(["personas"], personas); + client.setQueryData( + ["managed-agents"], + personas.map((p, i) => ({ + pubkey: i ? OTHER : AGENT, + name: p.displayName, + personaId: p.id, + status: "running", + })), + ); + client.setQueryData(["teams"], [team]); + }); + await settle(); + await act(async () => mention.openMentionPicker(1)); + const row = mention.suggestions.find((s) => s.kind === "team"); + assert.ok(row); + assert.equal( + new Set(row.teamMembers.map((m) => m.pubkey ?? m.personaId)).size, + 1, + ); + const insert = captureInsert(mention.selectMention); + await act(async () => + client.setQueryData(["teams"], [{ ...team, personaIds: ["one", "two"] }]), + ); + await settle(); + assert.equal( + new Set( + mention.suggestions + .find((s) => s.kind === "team") + .teamMembers.map((m) => m.pubkey ?? m.personaId), + ).size, + 1, + "the displayed team is frozen but current recipient admission is not", + ); + let edit; + await act(async () => { + edit = insert(row, 1); + }); + assert.equal(edit.insertText, ""); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []); +}); + +const keyboard = (key) => ({ key, nativeEvent: { key }, preventDefault() {} }); +const person = (pubkey, name = "Scout") => ({ + pubkey, + display_name: name, + is_agent: false, +}); + +for (const channelType of ["stream", "dm"]) { + test(`${channelType} without a destination does not wait for a disabled roster`, async () => { + await setup({ + channelId: null, + channelType, + searchUsers: [person(OTHER, "Alice")], + }); + assert.equal( + client.getQueryState(["channels", "none", "members"]).status, + "pending", + ); + assert.equal( + client.getQueryState(["channels", "none", "members"]).fetchStatus, + "idle", + ); + await act(async () => mention.updateMentionQuery("@Alice", 6)); + await settle(); + assert.equal(mention.isMentionLoading, false); + const choice = mention.handleMentionKeyDown(keyboard("Tab")).suggestion; + assert.equal(choice.pubkey, OTHER); + let edit; + await act(async () => { + edit = captureInsert(mention.selectMention)(choice, 6); + }); + assert.equal(mention.getDraftMentionRefs(edit.insertText)[0].pubkey, OTHER); + }); + + test(`${channelType} with a real pending roster still waits before admitting choices`, async () => { + let release; + await setup({ + channelType, + heldRoster: new Promise((resolve) => { + release = resolve; + }), + searchUsers: [person(OTHER, "Alice")], + }); + await act(async () => mention.updateMentionQuery("@Alice", 6)); + await settle(); + assert.equal(mention.isMentionLoading, true); + assert.deepEqual(mention.suggestions, []); + assert.equal( + mention.handleMentionKeyDown(keyboard("Tab")).suggestion, + undefined, + ); + await act(async () => release({ members: [] })); + await settle(); + assert.equal(mention.isMentionLoading, false); + assert.equal( + mention.handleMentionKeyDown(keyboard("Tab")).suggestion.pubkey, + OTHER, + ); + }); +} + +for (const selectionKey of ["Enter", "Tab"]) { + test(`background membership/search updates leave visible same-name rows and ${selectionKey} identity fixed`, async () => { + await setup({ + owner: OTHER, + searchUsers: [person(OTHER), person("e".repeat(64))], + }); + await act(async () => mention.updateMentionQuery("@Scout", 6)); + await settle(); + const displayed = mention.suggestions; + assert.equal(displayed.length, 2); + assert.deepEqual( + new Set(displayed.map((row) => row.pubkey)), + new Set([OTHER, "e".repeat(64)]), + ); + assert.ok(displayed.every((row) => row.hasNameCollision)); + await act(async () => mention.handleMentionKeyDown(keyboard("ArrowDown"))); + const selected = displayed[1]; + state.searchUsers = [ + person("e".repeat(64)), + person(OTHER), + person("d".repeat(64)), + ]; + await act(async () => + client.invalidateQueries({ queryKey: ["user-search"] }), + ); + await settle(); + assert.deepEqual(mention.suggestions, displayed); + assert.equal(mention.mentionSelectedIndex, 1); + let outcome, edit; + await act(async () => { + outcome = mention.handleMentionKeyDown(keyboard(selectionKey)); + }); + assert.deepEqual(outcome.suggestion, selected); + await act(async () => { + edit = captureInsert(mention.selectMention)(outcome.suggestion, 6); + }); + assert.equal( + mention.getDraftMentionRefs(edit.insertText)[0].pubkey, + selected.pubkey, + ); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), [ + selected.pubkey, + ]); + await act(async () => mention.updateMentionQuery("@Scou", 5)); + await settle(); + assert.equal(mention.suggestions.length, 3); + }); +} + +test("text changes load a new request; superseded and closed responses cannot install rows", async () => { + await setup(); + let releaseOld, releaseNew, releaseClosed; + state.pendingSearch = { + old: new Promise((resolve) => { + releaseOld = resolve; + }), + new: new Promise((resolve) => { + releaseNew = resolve; + }), + closed: new Promise((resolve) => { + releaseClosed = resolve; + }), + }; + await act(async () => mention.updateMentionQuery("@old", 4)); + await settle(); + assert.equal(mention.isMentionLoading, true); + assert.equal( + mention.handleMentionKeyDown(keyboard("Tab")).suggestion, + undefined, + ); + await act(async () => mention.updateMentionQuery("@new", 4)); + await settle(); + await act(async () => + releaseNew({ users: [person(OTHER, "New")], next_cursor: null }), + ); + await settle(); + const displayed = mention.suggestions; + assert.equal(displayed[0].pubkey, OTHER); + await act(async () => + releaseOld({ users: [person(VIEWER, "Old")], next_cursor: null }), + ); + await settle(); + assert.deepEqual(mention.suggestions, displayed); + await act(async () => mention.updateMentionQuery("@closed", 7)); + await settle(); + await act(async () => mention.cancelMentionAutocomplete()); + await act(async () => + releaseClosed({ users: [person(VIEWER, "Closed")], next_cursor: null }), + ); + await settle(); + assert.equal(mention.isMentionOpen, false); + assert.deepEqual(mention.suggestions, []); +}); + +test("leaving a completion and navigation discard choices; explicit reopen starts at zero", async () => { + await setup({ visible: true, directoryVisible: true }); + const old = rows()[0]; + await act(async () => mention.updateMentionQuery("plain", 5)); + assert.equal(mention.isMentionOpen, false); + await act(async () => { + assert.equal(captureInsert(mention.selectMention)(old, 5).insertText, ""); + }); + await act(async () => mention.openMentionPicker(5)); + assert.equal(mention.mentionSelectedIndex, 0); + assert.notEqual(rows()[0], old); + state.channelId = "another-channel"; + await render(); + assert.equal(mention.isMentionOpen, false); + assert.deepEqual(mention.suggestions, []); +}); + +test("Space completes an exact name but remains literal for partial and same-name choices", async () => { + await setup({ searchUsers: [person(OTHER, "Alice")] }); + await act(async () => mention.updateMentionQuery("@Ali", 4)); + await settle(); + assert.equal(mention.handleMentionKeyDown(keyboard(" ")).handled, false); + await act(async () => mention.updateMentionQuery("@Alice", 6)); + await settle(); + assert.equal( + mention.handleMentionKeyDown(keyboard(" ")).suggestion.pubkey, + OTHER, + ); + state.searchUsers = [person(OTHER, "Alice"), person("e".repeat(64), "Alice")]; + await act(async () => + client.invalidateQueries({ queryKey: ["user-search"] }), + ); + await act(async () => mention.updateMentionQuery("@ALICE", 6)); + await settle(); + assert.equal(mention.handleMentionKeyDown(keyboard(" ")).handled, false); + assert.equal( + mention.handleMentionKeyDown(keyboard("Tab")).suggestion.pubkey, + 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(getMentionSelectionHistory(VIEWER, CHANNEL), []); + 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", + ); + }); +} +// Observe the real admitted action after act flushes its promise; no authority stub. +function captureInsert(select) { + return (row, cursor) => { + const edit = { + insertText: "", + replaceFromOffset: cursor, + replaceToOffset: cursor, + }; + select( + row, + cursor, + () => true, + (committed) => Object.assign(edit, committed), + ); + return edit; + }; +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 7598e8db3e5..0e413ca8102 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -14,6 +14,7 @@ import { relayClient } from "@/shared/api/relayClient"; import { CHANNEL_EVENT_KINDS, CHANNEL_MESSAGE_EVENT_KINDS, + KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { @@ -22,6 +23,7 @@ import { } from "@/shared/lib/trailingDebounce"; import { isDmNotifiableKind } from "./isDmNotifiableKind"; +import { refreshDirectoryAfterMembershipChange } from "./membershipDirectorySync"; import { refreshChannelsWhenIdle } from "./refreshChannelsWhenIdle"; export type UseLiveChannelUpdatesOptions = { @@ -322,6 +324,27 @@ export function useLiveChannelUpdates( } } + // A membership change in any subscribed channel must refresh the mention + // directory. The mounted-channel refresh only covers the active channel + // and the global membership hook only covers viewer-addressed 44100/44101 + // rows, so background third-party changes would otherwise stay stale + // until the focused directory poll. The helper coalesces bursts and + // dedupes by event id. + if (event.kind === KIND_SYSTEM_MESSAGE) { + try { + const payload = JSON.parse(event.content) as { type?: string }; + if ( + payload.type === "member_joined" || + payload.type === "member_left" || + payload.type === "member_removed" + ) { + refreshDirectoryAfterMembershipChange(queryClient, event.id); + } + } catch { + // Non-JSON system message — ignore. + } + } + // Merge into the timeline cache for the active channel. // useChannelSubscription also writes to this cache, but there's a // race window where it hasn't connected yet. Writes are idempotent diff --git a/desktop/src/features/channels/useLiveChannelUpdatesMembershipDirectory.test.mjs b/desktop/src/features/channels/useLiveChannelUpdatesMembershipDirectory.test.mjs new file mode 100644 index 00000000000..1b58767e694 --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdatesMembershipDirectory.test.mjs @@ -0,0 +1,523 @@ +/** + * Mounted-caller regression for PR #7191 review claim 1 (review 5156991744): + * a third-party membership change in a BACKGROUND subscribed channel must + * refresh the viewer's mention directory. + * + * The seam under test is the real app-wide background receiver: + * `useLiveChannelUpdates`' `handleIncomingMessage` — the per-channel live + * subscription callback every channel row (kind 40099 included) already arrives + * through (AppShell → useUnreadChannels → useLiveChannelUpdates). The + * mounted-channel refresh (messages/hooks.ts) only runs for the channel on + * screen, and the global membership hook (useMembershipNotifications) only + * sees viewer-addressed 44100/44101 rows; neither covers this row, so before + * the fix the directory stayed stale until the 5-minute focused poll. + * + * Falsifiability: every assertion is driven by the ordinary relay wire shape — + * relay-keypair-signed kind:40099 rows with `{"type":"member_joined"| + * "member_left"|"member_removed","actor":A,"target":T}` content and an h tag + * (crates/buzz-relay/src/handlers/side_effects.rs: emit_system_message / + * handle_put_user / handle_remove_user). Deleting the + * `refreshDirectoryAfterMembershipChange` call in `handleIncomingMessage` + * fails these tests; a non-membership 40099 row must NOT refresh. The emit + * helper refuses to deliver a row the subscription filter would not carry, so + * the fixture cannot pass through a kind the real hook never receives. + * + * Harness shape: same pattern as useCommunityJoinAlerts.test.mjs — minimal + * DOM shim → __TAURI_INTERNALS__ interception → production imports → + * createRoot/act inside a QueryClientProvider. relayClient's subscription + * entry points are replaced with mock.method so no socket is opened. + */ + +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it, mock } from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children[this.children.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLElement = MinimalNode; + // react-dom's commit phase does `element instanceof window.HTMLIFrameElement` + // (getActiveElementDeep). Leaving it undefined throws out of commitRoot. + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── localStorage shim ──────────────────────────────────────────────────────── + +const storage = new Map(); +globalThis.localStorage = { + get length() { + return storage.size; + }, + key: (index) => [...storage.keys()][index] ?? null, + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + clear: () => storage.clear(), +}; +globalThis.window.localStorage = globalThis.localStorage; + +// ── Tauri IPC interceptor ──────────────────────────────────────────────────── +// +// The seam under test runs entirely on the query cache, so any Tauri command +// reaching this harness means the fixture drifted off the intended path. Fail +// loudly rather than silently letting a stray IPC call pass. + +globalThis.__TAURI_INTERNALS__ = { + invoke: (cmd) => + Promise.reject(new Error(`unexpected Tauri command on this seam: ${cmd}`)), + transformCallback: () => Math.random(), +}; + +// ── Production imports (after shims) ───────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { + QueryClient, + QueryClientProvider, + QueryObserver, +} from "@tanstack/react-query"; + +import { useLiveChannelUpdates } from "./useLiveChannelUpdates.ts"; +import { resetMembershipDirectorySync } from "./membershipDirectorySync.ts"; +import { relayClient } from "@/shared/api/relayClient.ts"; +import { + KIND_STREAM_MESSAGE, + KIND_SYSTEM_MESSAGE, +} from "@/shared/constants/kinds"; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const VIEWER = "a".repeat(64); // subscribed viewer, viewing another channel +const ACTOR = "b".repeat(64); // A: the member performing the change +const TARGET = "c".repeat(64); // T: the member being added/removed +const RELAY_KEY = "f".repeat(64); // emit_system_message signs 40099 rows +const BACKGROUND_CHANNEL = "11111111-1111-4111-8111-111111111111"; +const ACTIVE_CHANNEL = "22222222-2222-4222-8222-222222222222"; +const DIRECTORY_KEY = ["relay-agents"]; + +function channelFixture(id, name) { + return { + id, + name, + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [VIEWER, ACTOR], + lastMessageAt: null, + archivedAt: null, + participants: [VIEWER, ACTOR], + participantPubkeys: [VIEWER, ACTOR], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +/** + * The ordinary relay wire shape for a third-party membership change. + * `handle_put_user` (A adds T) emits member_joined; `handle_remove_user` + * emits member_removed (third party) or member_left (self). Actor and target + * ride in the content JSON; the row is signed by the relay keypair and + * addressed to the channel with its h tag. + */ +function membershipEvent({ id, type, channelId = BACKGROUND_CHANNEL }) { + return { + id, + kind: KIND_SYSTEM_MESSAGE, + pubkey: RELAY_KEY, + created_at: 2_000, + content: JSON.stringify({ type, actor: ACTOR, target: TARGET }), + tags: [["h", channelId]], + sig: "s".repeat(128), + }; +} + +/** + * Replace relayClient's subscription entry points and hand the test direct + * control of the per-channel live callbacks the hook registers. + */ +function installRelayStub() { + /** @type {Map void>>} */ + const liveByChannel = new Map(); + const subscribedKindsByChannel = new Map(); + const reconnectListeners = []; + + mock.method(relayClient, "subscribeLive", async (filter, onEvent) => { + const channelId = filter["#h"][0]; + if (!liveByChannel.has(channelId)) liveByChannel.set(channelId, []); + liveByChannel.get(channelId).push(onEvent); + subscribedKindsByChannel.set(channelId, new Set(filter.kinds)); + return async () => { + const list = liveByChannel.get(channelId) ?? []; + liveByChannel.set( + channelId, + list.filter((fn) => fn !== onEvent), + ); + }; + }); + + mock.method(relayClient, "subscribeToReconnects", (listener) => { + reconnectListeners.push(listener); + return () => { + const i = reconnectListeners.indexOf(listener); + if (i >= 0) reconnectListeners.splice(i, 1); + }; + }); + + // Not armed in this fixture (no onLiveMention), but mocked so an unexpected + // arm stays hermetic instead of opening a socket. + mock.method( + relayClient, + "subscribeToChannelMentionEvents", + async () => async () => {}, + ); + + return { + /** Live callbacks registered for a channel — the subscription precondition. */ + liveSubCount: (channelId) => (liveByChannel.get(channelId) ?? []).length, + /** + * Deliver an event down a channel's live callbacks, refusing rows the + * subscription filter would not carry — the fixture must be a row this + * hook really receives. + */ + emitLive: (channelId, event) => { + const kinds = subscribedKindsByChannel.get(channelId); + assert.ok( + kinds?.has(event.kind), + `fixture row kind ${event.kind} is not in the live subscription filter for ${channelId}`, + ); + for (const fn of liveByChannel.get(channelId) ?? []) fn(event); + }, + }; +} + +// ── Mount ───────────────────────────────────────────────────────────────────── + +let relay = null; +let queryClient = null; +let disposeObserver = null; +let root = null; +let harnessTree = null; +let directoryRoster = ["existing-agent"]; +let directoryFetches = 0; + +beforeEach(() => { + relay = installRelayStub(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + directoryFetches = 0; + + // The real app-wide mount: viewer subscribes to both channels but is + // viewing ACTIVE_CHANNEL, so BACKGROUND_CHANNEL is background. + function Harness() { + useLiveChannelUpdates( + [ + channelFixture(BACKGROUND_CHANNEL, "agents"), + channelFixture(ACTIVE_CHANNEL, "general"), + ], + ACTIVE_CHANNEL, + { currentPubkey: VIEWER }, + ); + return null; + } + + const container = document.createElement("div"); + root = createRoot(container); + harnessTree = React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness, null), + ); +}); + +afterEach(async () => { + // Drop the helper's coalesce timers so no pending flush leaks into the next + // test — same cleanup the helper's own suite performs. + resetMembershipDirectorySync(); + await act(async () => { + root.unmount(); + }); + disposeObserver?.(); + queryClient.clear(); +}); + +async function settle(iterations = 4) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + } +} + +/** + * Mount the real hook with a live ["relay-agents"] directory observer whose + * first read returns `initialRoster`. The roster variable is what "the relay + * now lists" for each subsequent fetch — tests flip it to model the change + * the refresh must pick up. + */ +async function mountWithDirectory(initialRoster) { + directoryRoster = initialRoster; + const observer = new QueryObserver(queryClient, { + queryKey: DIRECTORY_KEY, + queryFn: async () => { + directoryFetches += 1; + return directoryRoster; + }, + staleTime: Infinity, + }); + disposeObserver = observer.subscribe(() => {}); + + await act(async () => { + root.render(harnessTree); + }); + await settle(); +} + +describe("useLiveChannelUpdates membership directory refresh", () => { + it("background member_joined (A adds T) refreshes the mention directory", async () => { + await mountWithDirectory(["existing-agent"]); + assert.equal(relay.liveSubCount(BACKGROUND_CHANNEL), 1); + assert.equal(directoryFetches, 1); + assert.deepEqual(queryClient.getQueryData(DIRECTORY_KEY), [ + "existing-agent", + ]); + + // Ordinary background A→T add arriving on the channel's live subscription. + await act(async () => { + relay.emitLive( + BACKGROUND_CHANNEL, + membershipEvent({ id: "join-1", type: "member_joined" }), + ); + }); + + // The directory query is marked stale synchronously by the new wiring. + assert.equal( + queryClient.getQueryState(DIRECTORY_KEY).isInvalidated, + true, + "member_joined row must invalidate the mention directory", + ); + + // The relay's directory now lists T; the coalesced refetch must pick it up + // instead of waiting for the 5-minute focused poll. + directoryRoster = ["existing-agent", TARGET]; + await settleAfterDirectoryCoalesce(); + + assert.equal(directoryFetches, 2); + assert.deepEqual(queryClient.getQueryData(DIRECTORY_KEY), [ + "existing-agent", + TARGET, + ]); + }); + + it("background member_removed (A removes T) refreshes the mention directory", async () => { + await mountWithDirectory(["existing-agent", TARGET]); + assert.equal(relay.liveSubCount(BACKGROUND_CHANNEL), 1); + assert.equal(directoryFetches, 1); + assert.deepEqual(queryClient.getQueryData(DIRECTORY_KEY), [ + "existing-agent", + TARGET, + ]); + + await act(async () => { + relay.emitLive( + BACKGROUND_CHANNEL, + membershipEvent({ id: "remove-1", type: "member_removed" }), + ); + }); + + assert.equal( + queryClient.getQueryState(DIRECTORY_KEY).isInvalidated, + true, + "member_removed row must invalidate the mention directory", + ); + + // The relay no longer lists T; the refreshed directory must drop the + // removed member from mention candidates. + directoryRoster = ["existing-agent"]; + await settleAfterDirectoryCoalesce(); + + assert.equal(directoryFetches, 2); + assert.deepEqual(queryClient.getQueryData(DIRECTORY_KEY), [ + "existing-agent", + ]); + }); + + it("non-membership 40099 rows and plain messages do not refresh the directory", async () => { + await mountWithDirectory(["existing-agent"]); + assert.equal(relay.liveSubCount(BACKGROUND_CHANNEL), 1); + assert.equal(directoryFetches, 1); + + await act(async () => { + // An ordinary 40099 with a non-membership payload (topic_changed) — + // must not trigger the membership refresh. + relay.emitLive(BACKGROUND_CHANNEL, { + ...membershipEvent({ id: "topic-1", type: "topic_changed" }), + content: JSON.stringify({ + type: "topic_changed", + actor: ACTOR, + topic: "new topic", + }), + }); + // A plain stream message — not a membership change. + relay.emitLive(BACKGROUND_CHANNEL, { + id: "chat-1", + kind: KIND_STREAM_MESSAGE, + pubkey: ACTOR, + created_at: 2_001, + content: "hello", + tags: [["h", BACKGROUND_CHANNEL]], + sig: "s".repeat(128), + }); + }); + + await settleAfterDirectoryCoalesce(); + + assert.equal(directoryFetches, 1); + assert.equal( + queryClient.getQueryState(DIRECTORY_KEY).isInvalidated, + false, + "only member_* payloads may refresh the directory", + ); + }); +}); + +/** Outlive the helper's 200 ms coalesce window, then drain effects. */ +async function settleAfterDirectoryCoalesce() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + await settle(); +} diff --git a/desktop/src/features/channels/useMembershipNotifications.ts b/desktop/src/features/channels/useMembershipNotifications.ts index 7f8fa91a6a6..96657b66d2f 100644 --- a/desktop/src/features/channels/useMembershipNotifications.ts +++ b/desktop/src/features/channels/useMembershipNotifications.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { refreshDirectoryAfterMembershipChange } from "./membershipDirectorySync"; import { getChannelIdFromTags } from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; @@ -26,6 +27,7 @@ export function useMembershipNotifications(currentPubkey?: string) { return; } + refreshDirectoryAfterMembershipChange(queryClient, event.id); void queryClient.invalidateQueries({ queryKey: ["channels", channelId, "detail"], }); diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a3b17f5ea36..8ebca43f2e1 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,3 +1,4 @@ +import { resetMentionSelectionHistory } from "@/features/messages/lib/mentionSelectionHistory"; import { useEffect, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { isMacPlatform } from "@/shared/lib/platform"; @@ -38,6 +39,7 @@ import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useS import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; +import { resetMembershipDirectorySync } from "@/features/channels/membershipDirectorySync"; import { initFirstCommunity, @@ -58,6 +60,8 @@ async function resetCommunityState({ resetAvatarState: boolean; }): Promise { relayClient.disconnect(); + resetMembershipDirectorySync(); + resetMentionSelectionHistory(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d4c61892f8e..4a5c91887aa 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -1,3 +1,4 @@ +import { useMentionAdmissionEditor } from "@/features/messages/lib/useMentionAdmissionEditor"; import * as React from "react"; import { EditorContent } from "@tiptap/react"; @@ -74,7 +75,11 @@ export function ForumComposer({ if (compact) setIsCompactExpanded(true); }, [compact]); - const mentions = useMentions(channelId, members, profiles, { channelType }); + const mentions = useMentions(channelId, members, profiles, { + channelType, + getEditorSnapshot: (): { text: string; cursor: number } => + richText.getPlainTextAndCursor(), + }); const channelLinks = useChannelLinks(); const media = useMediaUpload(); const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } = @@ -127,6 +132,8 @@ export function ForumComposer({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, + onSelectionUpdate: ({ text, cursor }) => + mentions.updateMentionQuery(text, cursor), onUpdate: ({ cursor, text }) => { const markdown = richText.getMarkdown(); setContent(markdown); @@ -137,6 +144,8 @@ export function ForumComposer({ }, }); + useMentionAdmissionEditor(richText.editor, mentions.cancelMentionAdmission); + const linkEditor = useLinkEditor(richText); onEditLinkRef.current = linkEditor.openFromClick; onLinkSelectionChangeRef.current = linkEditor.showFromCursor; @@ -148,16 +157,21 @@ export function ForumComposer({ (suggestion: MentionSuggestion) => { if (isSubmissionPendingRef.current) return; const { cursor } = richText.getPlainTextAndCursor(); - const { replaceFromOffset, replaceToOffset, insertText } = - mentions.insertMention(suggestion, cursor); - richText.replacePlainTextRange( - replaceFromOffset, - replaceToOffset, - insertText, + mentions.selectMention( + suggestion, + cursor, + () => !isSubmissionPendingRef.current && !disabledRef.current, + ({ replaceFromOffset, replaceToOffset, insertText }) => { + richText.replacePlainTextRange( + replaceFromOffset, + replaceToOffset, + insertText, + ); + }, ); }, [ - mentions.insertMention, + mentions.selectMention, richText.getPlainTextAndCursor, richText.replacePlainTextRange, ], @@ -223,6 +237,7 @@ export function ForumComposer({ // ── Submit ────────────────────────────────────────────────────────── const submitMessage = React.useCallback( async (submitter = onSubmitRef.current) => { + mentions.cancelMentionAdmission(); const trimmed = contentRef.current.trim(); const currentPendingImeta = media.pendingImetaRef.current; const hasMedia = currentPendingImeta.length > 0; @@ -293,6 +308,7 @@ export function ForumComposer({ media.pendingImetaRef, media.setPendingImeta, mentions.cancelMentionAutocomplete, + mentions.cancelMentionAdmission, mentions.extractMentionPubkeys, mentions.revalidateMentionPubkeys, mentions.clearMentions, @@ -474,6 +490,9 @@ export function ForumComposer({ const autocompletePosition = autocompleteBelow ? "below" : "above"; return ( <> + + {mentions.mentionAdmissionStatus} +
@@ -572,7 +592,10 @@ export function ForumComposer({ {onCancel ? (
', + { pretendToBeVisual: true }, + ); + const saved = new Map(); + for (const key of [ + "window", + "document", + "navigator", + "HTMLElement", + "Node", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + ]) { + saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: + typeof dom.window[key] === "function" && + key.includes("AnimationFrame") + ? dom.window[key].bind(dom.window) + : dom.window[key], + }); + } + const editor = new Editor({ + element: document.querySelector("#editor"), + extensions: [StarterKit], + editorProps: { handleScrollToSelection: () => true }, + content: "

hello

", + }); + const held = []; + window.requestAnimationFrame = (callback) => { + held.push(callback); + return held.length; + }; + window.cancelAnimationFrame = () => {}; // A callback already delivered to a scheduler can still arrive. + globalThis.requestAnimationFrame = window.requestAnimationFrame; + const editorDOM = editor.view.dom; + const mobile = scenario === "iOS" || scenario === "Android"; + const safari = scenario === "Safari"; + if (mobile) + Object.defineProperty(navigator, "platform", { + value: scenario === "iOS" ? "iPhone" : "Android", + }); + if (safari) + Object.defineProperty(navigator, "userAgent", { + value: "Version/18.0 Safari/605.1.15", + }); + const nativeFocus = editorDOM.focus.bind(editorDOM); + const focusArgs = []; + editorDOM.focus = (...args) => { + focusArgs.push(args); + nativeFocus(...args); + }; + let disabled = false; + const cancel = scheduleComposerAutofocus(editor, () => disabled); + try { + assert.equal(held.length, 1); + if (mobile || safari) { + assert.equal( + document.activeElement, + editorDOM, + "platform preparation is immediate", + ); + assert.deepEqual(focusArgs[0], safari ? [{ preventScroll: true }] : []); + document.querySelector("button").focus(); + } + if (scenario === "pointer") + document.body.dispatchEvent( + new window.Event("pointerdown", { bubbles: true }), + ); + if (scenario === "keyboard") + document.body.dispatchEvent( + new window.Event("keydown", { bubbles: true }), + ); + if (scenario === "focus" || scenario === "explicit") + document.querySelector("button").focus(); + if (scenario === "retired" || scenario === "navigation") cancel(); + if (scenario === "destroyed") editor.destroy(); + if (scenario === "disabled") disabled = true; + if (scenario === "navigation") + scheduleComposerAutofocus(editor, () => disabled); + for (const callback of held) callback(0); + assert.equal( + document.activeElement === editorDOM, + ["initial", "navigation"].includes(scenario), + ); + if (scenario === "initial" || scenario === "navigation") + assert.equal(editor.state.selection.from, 6); + if (scenario === "explicit") { + editor.commands.focus("end"); + held.at(-1)(0); + assert.equal(document.activeElement, editor.view.dom); + } + } finally { + cancel(); + if (!editor.isDestroyed) editor.destroy(); + dom.window.close(); + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + } + }); +} diff --git a/desktop/src/features/messages/lib/scheduleComposerAutofocus.ts b/desktop/src/features/messages/lib/scheduleComposerAutofocus.ts new file mode 100644 index 00000000000..a8ebcf22b23 --- /dev/null +++ b/desktop/src/features/messages/lib/scheduleComposerAutofocus.ts @@ -0,0 +1,75 @@ +import { type Editor, isAndroid, isiOS, isSafari } from "@tiptap/core"; +import { Selection } from "@tiptap/pm/state"; + +/** One cancellable automatic-focus request. Explicit editor commands stay untouched. */ +export function scheduleComposerAutofocus( + editor: Editor, + disabled: () => boolean, +): () => void { + if (editor.isDestroyed || disabled()) return () => {}; + const view = editor.view; + const doc = view.dom.ownerDocument; + const win = doc.defaultView; + if (!win) return () => {}; + const active = doc.activeElement as HTMLElement | null; + if ( + active && + active !== doc.body && + (active.matches("input, textarea, select") || + active.isContentEditable || + active.closest('[role="menu"], [role="dialog"]')) + ) { + return () => {}; + } + + let retired = false; + let claimed = false; + const claim = () => { + claimed = true; + }; + const focusClaim = (event: Event) => { + if (!view.dom.contains(event.target as Node)) claim(); + }; + // Only observe while this request is pending; never suppress user events. + doc.addEventListener("pointerdown", claim, true); + doc.addEventListener("keydown", claim, true); + doc.addEventListener("focusin", focusClaim, true); + const removeListeners = () => { + doc.removeEventListener("pointerdown", claim, true); + doc.removeEventListener("keydown", claim, true); + doc.removeEventListener("focusin", focusClaim, true); + }; + const valid = () => + !retired && + !claimed && + !disabled() && + !editor.isDestroyed && + view.dom.isConnected && + editor.view === view; + + // Match Tiptap's immediate mobile/Safari preparation. Its focus command + // cannot be used here: it queues an unconditional, uncancellable inner RAF. + if (valid()) { + if (isiOS() || isAndroid()) view.dom.focus(); + else if (isSafari()) view.dom.focus({ preventScroll: true }); + } + const frame = win.requestAnimationFrame(function commitComposerAutofocus() { + if (valid()) { + const selection = Selection.atEnd(editor.state.doc); + if (!editor.state.selection.eq(selection)) { + view.dispatch(editor.state.tr.setSelection(selection)); + } + // Selection observers can synchronously transfer focus or retire scope. + if (valid()) { + view.focus(); + editor.commands.scrollIntoView(); + } + } + removeListeners(); + }); + return () => { + retired = true; + win.cancelAnimationFrame(frame); + removeListeners(); + }; +} diff --git a/desktop/src/features/messages/lib/useComposerAutofocus.ts b/desktop/src/features/messages/lib/useComposerAutofocus.ts index 1b6bbac79ef..d94d600c6b1 100644 --- a/desktop/src/features/messages/lib/useComposerAutofocus.ts +++ b/desktop/src/features/messages/lib/useComposerAutofocus.ts @@ -1,13 +1,13 @@ +import type { Editor } from "@tiptap/core"; import * as React from "react"; +import { scheduleComposerAutofocus } from "./scheduleComposerAutofocus"; /** * Focus the composer editor on mount and whenever the active draft key * changes (channel switch, thread open). * * Matches the behaviour of Slack/Discord/Signal: the composer is ready to - * accept typing without an explicit click. The `focus` callback is expected - * to no-op until the underlying editor is mounted, and to change identity - * once that happens — so listing it as a dep recovers from the + * accept typing without an explicit click. Editor identity recovers from the * editor-not-ready-yet case on first render. * * The effect trigger deliberately excludes `disabled`: callers pass a @@ -24,7 +24,7 @@ import * as React from "react"; * dialog input, search box, etc.) so we don't yank focus from the user. */ export function useComposerAutofocus( - focus: () => void, + editor: Editor | null, draftKey: string | null | undefined, disabled: boolean, ) { @@ -34,21 +34,8 @@ export function useComposerAutofocus( disabledRef.current = disabled; // biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the trigger; disabled is read via ref - React.useEffect(() => { - if (disabledRef.current) return; - if (typeof document === "undefined") return; - const active = document.activeElement as HTMLElement | null; - if (active && active !== document.body) { - const tag = active.tagName; - if ( - tag === "INPUT" || - tag === "TEXTAREA" || - tag === "SELECT" || - active.isContentEditable - ) { - return; - } - } - focus(); - }, [draftKey, focus]); + React.useLayoutEffect(() => { + if (!editor) return; + return scheduleComposerAutofocus(editor, () => disabledRef.current); + }, [draftKey, editor]); } diff --git a/desktop/src/features/messages/lib/useMentionAdmission.ts b/desktop/src/features/messages/lib/useMentionAdmission.ts new file mode 100644 index 00000000000..e4171115099 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmission.ts @@ -0,0 +1,112 @@ +import * as React from "react"; +import { AgentMentionAuthorizationError } from "./agentMentionRevalidation"; + +/** One cancellable prepare/commit owner. No caller may mutate while preparing. */ +export function useMentionAdmission(scope: object) { + const activeKey = React.useRef(null); + const activeValid = React.useRef<(() => boolean) | null>(null); + const generation = React.useRef(0); + const timer = React.useRef | undefined>( + undefined, + ); + const releaseNavigation = React.useRef<(() => void) | undefined>(undefined); + const [status, setStatus] = React.useState(""); + const cancel = React.useCallback(() => { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + generation.current += 1; + activeKey.current = null; + activeValid.current = null; + clearTimeout(timer.current); + setStatus(""); + }, []); + // Losing live eligibility abandons this operation, even if Retry later + // restores the same row before its older prepare promise settles. + React.useLayoutEffect(() => { + if (activeValid.current && !activeValid.current()) cancel(); + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: scope changes abandon the operation even when the value returns later. + React.useLayoutEffect(() => { + cancel(); + return () => { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + generation.current += 1; + clearTimeout(timer.current); + }; + }, [scope, cancel]); + const begin = React.useCallback( + (operation: { + key: object; + valid: () => boolean; + prepare: () => Promise; + commit: () => void; + }) => { + if (activeKey.current === operation.key) return; + cancel(); + if (!operation.valid()) return; + activeKey.current = operation.key; + activeValid.current = operation.valid; + // Admission belongs to the focused action, not the composer's wider + // focus ownership. Observe its departure even inside an overlay/portal; + // returning later must not resurrect the pending operation. + const origin = document.activeElement; + const view = origin?.ownerDocument.defaultView; + origin?.addEventListener("blur", cancel); + view?.addEventListener("blur", cancel); + releaseNavigation.current = () => { + origin?.removeEventListener("blur", cancel); + view?.removeEventListener("blur", cancel); + }; + const id = generation.current; + const current = () => id === generation.current && operation.valid(); + setStatus("Checking access…"); + timer.current = setTimeout(() => { + if (id !== generation.current) return; + const valid = operation.valid(); + cancel(); + if (valid) setStatus("Could not check access. Select again to retry."); + }, 15000); + void (async () => { + let committing = false; + try { + await operation.prepare(); + if (!current()) return; + clearTimeout(timer.current); + setStatus(""); + // No await between the final fence and the complete consumer commit. + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + committing = true; + operation.commit(); + } catch (error) { + if (committing) { + console.error("Mention selection commit failed", error); + setStatus( + "Could not finish selection. Check the draft before retrying.", + ); + return; + } + if (!current()) return; + setStatus( + error instanceof AgentMentionAuthorizationError && + error.reason === "denied" + ? "Access changed. Selection was not inserted. Select again to retry." + : "Could not check access. Select again to retry.", + ); + } finally { + if (id === generation.current) { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + clearTimeout(timer.current); + activeKey.current = null; + activeValid.current = null; + if (!committing && !operation.valid()) setStatus(""); + } + } + })(); + }, + [cancel], + ); + return { begin, cancel, status }; +} diff --git a/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs b/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs new file mode 100644 index 00000000000..86b3e321312 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + for (const key of [ + "window", + "document", + "DOMParser", + "Element", + "HTMLElement", + "Node", + "MutationObserver", + "Event", + "KeyboardEvent", + ]) + globalThis[key] = dom.window[key]; + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); +afterEach(async () => (await import("@testing-library/react")).cleanup()); +after(() => dom.window.close()); + +async function tools() { + return { + React: await import("react"), + ...(await import("@testing-library/react")), + ...(await import("@tiptap/react")), + StarterKit: (await import("@tiptap/starter-kit")).default, + ...(await import("./useMentionAdmissionEditor.ts")), + }; +} + +test("useEditor replacement can retire the captured editor before the admission effect", async () => { + const { + React, + render, + act, + useEditor, + EditorContent, + StarterKit, + useMentionAdmissionEditor, + } = await tools(); + const instances = []; + function Harness({ revision }) { + const editor = useEditor({ extensions: [StarterKit] }, [revision]); + if (editor && !instances.includes(editor)) instances.push(editor); + const cancel = React.useCallback(() => {}, [revision]); + useMentionAdmissionEditor(editor, cancel); + return React.createElement(EditorContent, { editor }); + } + const mounted = render(React.createElement(Harness, { revision: 0 })); + await act(async () => + mounted.rerender(React.createElement(Harness, { revision: 1 })), + ); + assert.equal(instances.length, 2); + assert.equal(instances[0].isDestroyed, true); + assert.equal(instances[1].isDestroyed, false); +}); + +test("real mount/unmount/remount and replacement retain guards without stale DOM listeners", async () => { + const { React, render, Editor, StarterKit, useMentionAdmissionEditor } = + await tools(); + const editor = new Editor({ + element: null, + extensions: [StarterKit], + content: "hello", + // The chooser consumes these keys in production. Keep ProseMirror's + // default Enter edit separate from the native cancellation assertion. + editorProps: { handleKeyDown: () => true }, + }); + const replacement = new Editor({ + element: null, + extensions: [StarterKit], + editorProps: { handleKeyDown: () => true }, + }); + let cancellations = 0; + const cancel = () => { + cancellations++; + }; + function Harness({ editor }) { + useMentionAdmissionEditor(editor, cancel); + return null; + } + const mounted = render(React.createElement(Harness, { editor })); + function guarded(instance) { + const element = instance.view.dom; + for (const type of ["beforeinput", "pointerdown"]) { + const before = cancellations; + element.dispatchEvent(new Event(type)); + assert.equal(cancellations, before + 1, type); + } + for (const key of ["Enter", "Tab", " ", "ArrowLeft", "Escape", "x"]) { + const before = cancellations; + element.dispatchEvent(new KeyboardEvent("keydown", { key })); + assert.equal( + cancellations, + before + (["Enter", "Tab", " "].includes(key) ? 0 : 1), + key, + ); + } + let before = cancellations; + instance.view.dispatch(instance.state.tr); + assert.equal(cancellations, before, "no-op transaction"); + instance.commands.setTextSelection(1); + assert.equal(cancellations, before + 1, "explicit selection transaction"); + before = cancellations; + instance.commands.insertContent("a"); + assert.equal(cancellations, before + 1, "document transaction"); + return element; + } + function inert(element) { + const before = cancellations; + for (const type of ["beforeinput", "pointerdown"]) + element.dispatchEvent(new Event(type)); + element.dispatchEvent(new KeyboardEvent("keydown", { key: "x" })); + assert.equal( + cancellations, + before, + "retired DOM has no admission listeners", + ); + } + try { + assert.throws(() => editor.view.dom, /editor view is not available/); + editor.mount(document.createElement("div")); + const first = guarded(editor); + let before = cancellations; + editor.unmount(); + assert.ok(cancellations > before, "unmount invalidates pending work"); + inert(first); + editor.mount(document.createElement("div")); + const second = guarded(editor); + assert.notEqual(first, second); + inert(first); + before = cancellations; + mounted.rerender(React.createElement(Harness, { editor: replacement })); + assert.ok(cancellations > before, "replacement invalidates pending work"); + inert(second); + replacement.mount(document.createElement("div")); + const third = guarded(replacement); + before = cancellations; + replacement.destroy(); + assert.ok(cancellations > before, "destroy invalidates pending work"); + inert(third); + assert.throws(() => replacement.view.dom, /editor view is not available/); + mounted.unmount(); // cleanup must not read the now-unavailable view + } finally { + editor.destroy(); + replacement.destroy(); + } +}); + +test("unmount fences real pending admission across remount; mounted admission still commits", async () => { + const { React, render, act, Editor, StarterKit, useMentionAdmissionEditor } = + await tools(); + const { useMentionAdmission } = await import("./useMentionAdmission.ts"); + const editor = new Editor({ extensions: [StarterKit] }); + const scope = {}; + let admission; + let commits = 0; + function Harness() { + admission = useMentionAdmission(scope); + useMentionAdmissionEditor(editor, admission.cancel); + return null; + } + const mounted = render(React.createElement(Harness)); + let release; + const prepared = new Promise((resolve) => { + release = resolve; + }); + try { + await act(async () => + admission.begin({ + key: {}, + valid: () => true, + prepare: () => prepared, + commit: () => { + commits++; + }, + }), + ); + assert.equal(admission.status, "Checking access…"); + await act(async () => { + editor.unmount(); + editor.mount(document.createElement("div")); + release(); + await prepared; + }); + assert.equal(commits, 0); + assert.equal(admission.status, ""); + await act(async () => + admission.begin({ + key: {}, + valid: () => true, + prepare: async () => {}, + commit: () => { + commits++; + }, + }), + ); + assert.equal(commits, 1, "new mounted operation is not disabled"); + } finally { + mounted.unmount(); + editor.destroy(); + } +}); diff --git a/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts new file mode 100644 index 00000000000..5b817554c84 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts @@ -0,0 +1,52 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; + +/** Native edits and selection transactions abandon authority, even after undo/return. */ +export function useMentionAdmissionEditor( + editor: Editor | null, + cancel: () => void, +) { + React.useEffect(() => { + if (!editor) return; + const transaction = ({ + transaction, + }: { + transaction: { docChanged: boolean; selectionSet: boolean }; + }) => { + if (transaction.docChanged || transaction.selectionSet) cancel(); + }; + let dom: HTMLElement | null = null; + const keydown = (event: KeyboardEvent) => { + if (!["Enter", "Tab", " "].includes(event.key)) cancel(); + }; + const detach = () => { + cancel(); + editor.off("transaction", transaction); + dom?.removeEventListener("keydown", keydown); + dom?.removeEventListener("beforeinput", cancel); + dom?.removeEventListener("pointerdown", cancel); + dom = null; + }; + const attach = () => { + if (dom) detach(); + // TipTap's public isDestroyed is also true when there is no view. + // isInitialized/create are delayed until after mount, so cannot gate this. + if (editor.isDestroyed) return; + dom = editor.view.dom; + editor.on("transaction", transaction); + dom.addEventListener("keydown", keydown); + dom.addEventListener("beforeinput", cancel); + dom.addEventListener("pointerdown", cancel); + }; + editor.on("mount", attach); + editor.on("unmount", detach); + editor.on("destroy", detach); + attach(); + return () => { + editor.off("mount", attach); + editor.off("unmount", detach); + editor.off("destroy", detach); + detach(); + }; + }, [editor, cancel]); +} 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 new file mode 100644 index 00000000000..1c625ef4df8 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionQuery.ts @@ -0,0 +1,118 @@ +import * as React from "react"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; + +type EditorSnapshot = { text: string; cursor: number }; +export type MentionRequest = EditorSnapshot & { + query: string; + startIndex: number; + explicit: boolean; + firstAgent: boolean; + scope: object; +}; + +/** One open completion request. Closing or changing text abandons its results. */ +export function useMentionQuery( + getSnapshot: (() => EditorSnapshot) | undefined, + scope: object, +) { + const [request, setRequest] = React.useState(null); + const current = React.useRef(request); + const revision = React.useRef(0); + const input = React.useRef({ text: "", cursor: 0 }); + const snapshot = React.useRef(getSnapshot); + snapshot.current = getSnapshot; + const searchableNamesLowerRef = React.useRef([]); + const publish = React.useCallback((next: MentionRequest | null) => { + revision.current += 1; + current.current = next; + setRequest(next); + }, []); + const cancel = React.useCallback(() => publish(null), [publish]); + React.useEffect(() => { + if (current.current?.scope !== scope) cancel(); + return () => { + current.current = null; + }; + }, [scope, cancel]); + const read = React.useCallback( + () => snapshot.current?.() ?? input.current, + [], + ); + const prefixFor = React.useCallback( + ({ text, cursor }: EditorSnapshot) => + detectPrefixQuery("@", text, cursor, searchableNamesLowerRef.current), + [], + ); + const update = React.useCallback( + (text: string, cursor: number) => { + const previous = input.current; + input.current = { text, cursor }; + if (previous.text === text && previous.cursor === cursor) return; + revision.current += 1; + const prefix = prefixFor(input.current); + const old = current.current; + // Moving out of the completion (or moving in a no-trigger menu) closes it. + if (!prefix && !(old?.explicit && previous.text !== text)) { + cancel(); + return; + } + if ( + old?.scope === scope && + prefix?.query === old.query && + prefix?.startIndex === old.startIndex + ) + return; + publish({ + text, + cursor, + scope, + query: prefix?.query ?? "", + startIndex: prefix?.startIndex ?? cursor, + explicit: !prefix, + firstAgent: false, + }); + }, + [cancel, prefixFor, publish, scope], + ); + const open = React.useCallback( + (cursor: number, firstAgent = false) => { + const value = { ...read(), cursor }; + input.current = value; + publish({ + ...value, + query: "", + startIndex: cursor, + explicit: true, + firstAgent, + scope, + }); + }, + [publish, read, scope], + ); + const isCurrent = React.useCallback(() => { + if (!request || current.current !== request || request.scope !== scope) + return false; + const live = read(); + if (request.explicit) + return live.text === request.text && live.cursor === request.cursor; + const prefix = prefixFor(live); + return ( + prefix?.startIndex === request.startIndex && + prefix.query === request.query + ); + }, [prefixFor, read, request, scope]); + return { + getRevision: () => revision.current, + request: request?.scope === scope ? request : null, + cancel, + refresh: React.useCallback(() => { + if (current.current) publish({ ...current.current }); + }, [publish]), + update, + open, + read, + isCurrent, + searchableNamesLowerRef, + currentPrefix: () => prefixFor(read()), + }; +} diff --git a/desktop/src/features/messages/lib/useMentionSelection.ts b/desktop/src/features/messages/lib/useMentionSelection.ts index fb0dee27a55..df0baffc46a 100644 --- a/desktop/src/features/messages/lib/useMentionSelection.ts +++ b/desktop/src/features/messages/lib/useMentionSelection.ts @@ -1,40 +1,55 @@ import * as React from "react"; +import type { MentionSuggestion } from "../ui/MentionAutocomplete"; +import type { MentionRequest } from "./useMentionQuery"; -import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; - -export type MentionPickerMode = "first-agent" | "preserve" | null; - -export function useMentionSelection(suggestions: MentionSuggestion[]) { - const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0); - const preferAgentSelectionRef = React.useRef(false); +export type MentionPickerMode = "first-agent" | null; +/** Install once per request; indexes refer to these displayed rows, never live ranking. */ +export function useMentionSelection( + request: MentionRequest | null, + candidates: MentionSuggestion[], + ready: boolean, +) { + const [snapshot, setSnapshot] = React.useState<{ + request: MentionRequest; + rows: MentionSuggestion[]; + index: number; + } | null>(null); React.useEffect(() => { - setMentionSelectedIndex((current) => { - if (suggestions.length === 0) return 0; - if (preferAgentSelectionRef.current) { - preferAgentSelectionRef.current = false; - const firstAgentIndex = suggestions.findIndex( - (suggestion) => suggestion.isAgent && suggestion.pubkey, - ); - if (firstAgentIndex >= 0) return firstAgentIndex; - } - return Math.min(current, suggestions.length - 1); - }); - }, [suggestions]); - - const clearAgentSelectionPreference = React.useCallback(() => { - preferAgentSelectionRef.current = false; - }, []); - const prepareSelectionPreference = React.useCallback( - (preference: MentionPickerMode) => { - preferAgentSelectionRef.current = preference === "first-agent"; - }, - [], - ); + if (!request) { + setSnapshot(null); + return; + } + if (!ready) return; + setSnapshot((old) => + old?.request === request + ? old + : { + request, + rows: candidates.map((row) => ({ ...row })), + index: request.firstAgent + ? Math.max( + 0, + candidates.findIndex((s) => s.isAgent && s.pubkey), + ) + : 0, + }, + ); + }, [request, candidates, ready]); + const installed = snapshot?.request === request ? snapshot : null; return { - clearAgentSelectionPreference, - mentionSelectedIndex, - prepareSelectionPreference, - setMentionSelectedIndex, + suggestions: installed?.rows ?? [], + mentionSelectedIndex: installed?.index ?? 0, + isLoading: !!request && !installed, + move: (direction: number) => + setSnapshot((old) => + !old || old.request !== request || !old.rows.length + ? old + : { + ...old, + index: + (old.index + direction + old.rows.length) % old.rows.length, + }, + ), }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 2bca5a0453f..49f4e199b7d 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,3 +1,14 @@ +import { useMentionAdmission } from "./useMentionAdmission"; +import { + isMentionActionable, + markMentionCollisions, +} from "./mentionPresentation"; +import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; +import { + getMentionSelectionHistory, + rememberMentionSelection, +} from "./mentionSelectionHistory"; +import { useMentionEvidence } from "./useMentionEvidence"; import * as React from "react"; import { useManagedAgentsQuery, @@ -12,7 +23,6 @@ import { import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { - filterCachedAgentSuggestions, getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, @@ -29,12 +39,11 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import type { AutocompleteEdit } from "./useRichTextEditor"; import type { ChannelMember, ChannelType } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import { useMentionQuery } from "./useMentionQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; -import { flushMentionDebounce, isPlainSpace } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { extractMentionPubkeys, @@ -58,14 +67,15 @@ import { appendUniqueName, buildTeamMentionCandidates, formatTeamMention, - type MentionCandidate, + sameTeamMentionRecipients, } from "./mentionCandidates"; -import { buildMentionCandidates } from "./buildMentionCandidates"; -const MENTION_DEBOUNCE_MS = 120, - MENTION_SUGGESTION_LIMIT = 50; +import { buildMentionCandidateProjection } from "./buildMentionCandidates"; +const MENTION_SUGGESTION_LIMIT = 50; type UseMentionsOptions = { channelType?: ChannelType | null; recentMentionPubkeys?: readonly string[]; + /** Read document and selection from one live editor state at commit time. */ + getEditorSnapshot?: () => { text: string; cursor: number }; }; export function useMentions( channelId: string | null, @@ -73,11 +83,21 @@ export function useMentions( profiles?: UserProfileLookup, options?: UseMentionsOptions, ) { - const [mentionQuery, setMentionQuery] = React.useState(null); - const [mentionStartIndex, setMentionStartIndex] = React.useState(0); - const mentionPickerOriginRef = React.useRef<"inline" | "explicit" | null>( - null, - ); + const canInviteNonMembers = useCanAddChannelMembers(channelId); + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : null; + const admissionScope = React.useMemo( + () => ({ currentPubkey, channelId }), + [currentPubkey, channelId], + ); + const query = useMentionQuery(options?.getEditorSnapshot, admissionScope); + const admission = useMentionAdmission(query.request ?? admissionScope); + const mentionQuery = query.request?.query ?? null; + const mentionStartIndex = query.request?.startIndex ?? 0; + const { searchableNamesLowerRef, currentPrefix: currentMentionPrefix } = + query; const [selectedMentionNames, setSelectedMentionNames] = React.useState< string[] >([]); @@ -88,13 +108,8 @@ export function useMentions( selectedAgentMentionNamesRef.current = selectedAgentMentionNames; const mentionMapRef = React.useRef>(new Map()); const personaMentionMapRef = React.useRef>(new Map()); - const previousSuggestionsRef = React.useRef([]); const mentionSearchQuery = mentionQuery?.trim() ?? ""; const canSearchGlobalPeople = mentionSearchQuery.length > 0; - const identityQuery = useIdentityQuery(); - const currentPubkey = identityQuery.data?.pubkey - ? normalizePubkey(identityQuery.data.pubkey) - : null; const membersQuery = useChannelMembersQuery(channelId); const members = externalMembers ?? membersQuery.data; const isArchivedDiscovery = useIsArchivedPredicate(); @@ -110,7 +125,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( @@ -215,10 +237,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], @@ -245,10 +263,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, @@ -272,6 +328,11 @@ export function useMentions( }), [ activePersonaById, + knownAgentPubkeys, + verificationFailed, + verificationPending, + canInviteNonMembers, + presenceFresh, activeAgentPubkeys, activePersonas, userSearchResults, @@ -294,15 +355,18 @@ export function useMentions( relayAgentsQuery.data, ], ); + const { candidates: mentionCandidates, evidence: mentionCandidateEvidence } = + candidateProjection; const mentionCandidatesWithTeams = React.useMemo( - () => [ - ...mentionCandidates, - ...buildTeamMentionCandidates( - teamsQuery.data ?? [], - personasQuery.data ?? [], - mentionCandidates, - ), - ], + () => + markMentionCollisions([ + ...mentionCandidates, + ...buildTeamMentionCandidates( + teamsQuery.data ?? [], + personasQuery.data ?? [], + mentionCandidates, + ), + ]), [mentionCandidates, personasQuery.data, teamsQuery.data], ); const ownerPubkeys = React.useMemo( @@ -350,22 +414,10 @@ export function useMentions( () => searchableNames.map((n) => n.toLowerCase()), [searchableNames], ); - const debounceTimerRef = React.useRef | null>( - null, - ); - const latestValueRef = React.useRef(""); - const latestCursorRef = React.useRef(0); - const flushedMentionStartIndexRef = React.useRef(null); - const searchableNamesLowerRef = React.useRef(searchableNamesLower); searchableNamesLowerRef.current = searchableNamesLower; - React.useEffect( - () => () => { - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - } - }, - [], - ); + const retryMention = React.useCallback(() => { + retryVerification(); + }, [retryVerification]); const matchingSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; @@ -374,10 +426,11 @@ export function useMentions( mentionCandidatesWithTeams, mentionQuery, activePersonaIds, + getMentionSelectionHistory(currentPubkey, channelId), ) .slice(0, MENTION_SUGGESTION_LIMIT) - .map(({ candidate, label }) => - mapMentionCandidateToSuggestion({ + .map(({ candidate, label }) => ({ + ...mapMentionCandidateToSuggestion({ agentProvenanceReady: agentDirectoriesReady, candidate, label, @@ -386,10 +439,13 @@ export function useMentions( ownerProfiles: ownerProfilesQuery.data?.profiles, profiles, }), - ); + onRetry: candidate.action === "unavailable" ? retryMention : undefined, + })); }, [ activePersonaIds, agentDirectoriesReady, + retryMention, + channelId, currentPubkey, mentionCandidatesWithTeams, mentionQuery, @@ -397,7 +453,7 @@ export function useMentions( ownerProfilesQuery.data?.profiles, profiles, ]); - const getDefaultAgentSuggestion = useDefaultAgentSuggestion({ + const defaultAgentSuggestion = useDefaultAgentSuggestion({ activePersonaIds, agentProvenanceReady: agentDirectoriesReady, candidates: mentionCandidates, @@ -407,52 +463,161 @@ export function useMentions( profiles, recentMentionPubkeys: options?.recentMentionPubkeys, }); - const fetchMoreSuggestions = React.useCallback(() => { - if (userSearchQuery.hasNextPage && !userSearchQuery.isFetchingNextPage) { - void userSearchQuery.fetchNextPage(); - } - }, [userSearchQuery]); - const suggestions = React.useMemo(() => { - if (mentionQuery === null) { - return []; - } - if (matchingSuggestions.length > 0) { - return matchingSuggestions; - } - if (userSearchQuery.isFetching) { - return filterCachedAgentSuggestions( - previousSuggestionsRef.current, - mentionCandidatesWithTeams, - ); - } - return []; - }, [ + // The closed-picker shortcut installs one exact choice, not a retained list. + const defaultChoice = React.useRef<{ + row: MentionSuggestion | null; + revision: number; + } | null>(null); + const getDefaultAgentSuggestion = () => { + if (admissionRef.current.scope !== admissionScope) return null; + const row = defaultAgentSuggestion(); + defaultChoice.current = { row, revision: query.getRevision() }; + return row; + }; + // 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 = + 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, - mentionCandidatesWithTeams, - mentionQuery, - userSearchQuery.isFetching, - ]); - React.useEffect(() => { - if (mentionQuery === null) { - previousSuggestionsRef.current = []; - return; - } - if (matchingSuggestions.length > 0) { - previousSuggestionsRef.current = matchingSuggestions; - } else if (!userSearchQuery.isFetching) { - previousSuggestionsRef.current = []; - } - }, [matchingSuggestions, mentionQuery, userSearchQuery.isFetching]); - const mentionSelection = useMentionSelection(suggestions); - const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } = - mentionSelection; - const isMentionOpen = mentionQuery !== null && suggestions.length > 0; + resultsReady, + ); + const { + 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 rowOwners = React.useRef(new WeakMap()); + 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, + ); + const overlay: MentionSuggestion = { + ...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, + }; + rowOwners.current.set(overlay, row); + return overlay; + }), + [ + 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. + const admissionRef = React.useRef({ + scope: admissionScope, + candidates: mentionCandidatesWithTeams, + }); + admissionRef.current = { + scope: admissionScope, + candidates: mentionCandidatesWithTeams, + }; + const canSelectMention = React.useCallback( + (suggestion: MentionSuggestion) => { + const current = admissionRef.current.candidates.find((candidate) => + suggestion.pubkey + ? candidate.pubkey === normalizePubkey(suggestion.pubkey) + : suggestion.teamId + ? candidate.teamId === suggestion.teamId + : !!suggestion.personaId && + candidate.personaId === suggestion.personaId, + ); + return ( + admissionRef.current.scope === admissionScope && + !!current && + (current.kind !== "team" || + (suggestion.kind === "team" && + !!suggestion.teamMembers?.length && + sameTeamMentionRecipients( + suggestion.teamMembers, + current.teamMembers, + ) && + suggestion.teamMembers.every((member) => { + const matches = (target: { + pubkey?: string; + personaId?: string | null; + }) => + member.pubkey + ? target.pubkey === normalizePubkey(member.pubkey) + : !!member.personaId && + !target.pubkey && + target.personaId === member.personaId; + return ( + current.teamMembers?.some(matches) && + admissionRef.current.candidates.some( + (target) => matches(target) && isMentionActionable(target), + ) + ); + }))) && + isMentionActionable(current) && + isMentionActionable(suggestion) + ); + }, + [admissionScope], + ); + // Private synchronous commit; public user choices must pass admitMention. const insertMention = React.useCallback( (suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => { - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } + const prefix = currentMentionPrefix(); + if ( + !query.isCurrent() || + !snapshotSuggestions.some( + (row) => row === rowOwners.current.get(suggestion), + ) || + !canSelectMention(suggestion) || + selectionEnd !== query.read().cursor || + (prefix && prefix.startIndex > selectionEnd) || + (!prefix && !query.request?.explicit) + ) + return { + replaceFromOffset: selectionEnd, + replaceToOffset: selectionEnd, + insertText: "", + }; + if (suggestion.pubkey) + rememberMentionSelection(currentPubkey, channelId, suggestion.pubkey); const [boundSuggestion] = selectedMentionLabels( [suggestion], mentionMapRef.current, @@ -513,19 +678,23 @@ export function useMentions( } trimMapToSize(mentions, 200); trimMapToSize(personaMentions, 200); - mentionPickerOriginRef.current = null; - setMentionQuery(null); - setSelected(0); - const startIndex = - flushedMentionStartIndexRef.current ?? mentionStartIndex; - flushedMentionStartIndexRef.current = null; + query.cancel(); + const startIndex = prefix?.startIndex ?? selectionEnd; return { replaceFromOffset: startIndex, replaceToOffset: selectionEnd, insertText, }; }, - [knownAgentPubkeys, mentionStartIndex, setSelected], + [ + canSelectMention, + currentMentionPrefix, + knownAgentPubkeys, + query, + snapshotSuggestions, + currentPubkey, + channelId, + ], ); const registerMentionPubkey = React.useCallback( (displayName: string, pubkey: string, options?: { isAgent?: boolean }) => { @@ -555,29 +724,6 @@ export function useMentions( }, [], ); - const insertResolvedMention = React.useCallback( - ({ - displayName, - pubkey, - replaceFromOffset, - replaceToOffset, - isAgent = false, - }: { - displayName: string; - pubkey: string; - replaceFromOffset: number; - replaceToOffset: number; - isAgent?: boolean; - }): AutocompleteEdit => { - const label = registerMentionPubkey(displayName, pubkey, { isAgent }); - return { - replaceFromOffset, - replaceToOffset, - insertText: `@${label ?? displayName.trim()} `, - }; - }, - [registerMentionPubkey], - ); const getMentionDisplayName = React.useCallback( (pubkey: string): string | null => { const normalizedPubkey = normalizePubkey(pubkey); @@ -605,70 +751,15 @@ export function useMentions( [managedAgentPubkeys], ); const isInlineMentionSelection = React.useCallback( - () => mentionPickerOriginRef.current === "inline", - [], - ); - const autocompleteGenerationRef = React.useRef(0); - const updateMentionQuery = React.useCallback( - (value: string, cursorPosition: number) => { - mentionSelection.clearAgentSelectionPreference(); - const generation = ++autocompleteGenerationRef.current; - latestValueRef.current = value; - latestCursorRef.current = cursorPosition; - const activeInlineMention = detectPrefixQuery( - "@", - value, - cursorPosition, - searchableNamesLowerRef.current, - ); - if (activeInlineMention) { - mentionPickerOriginRef.current = "inline"; - } else if (mentionPickerOriginRef.current === "inline") { - mentionPickerOriginRef.current = null; - } - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - } - debounceTimerRef.current = setTimeout(() => { - debounceTimerRef.current = null; - if (generation !== autocompleteGenerationRef.current) return; - const mention = detectPrefixQuery( - "@", - latestValueRef.current, - latestCursorRef.current, - searchableNamesLowerRef.current, - ); - if (mention) { - mentionPickerOriginRef.current = "inline"; - setMentionQuery(mention.query); - setMentionStartIndex(mention.startIndex); - setSelected(0); - } else { - setMentionQuery(null); - } - }, MENTION_DEBOUNCE_MS); - }, - [mentionSelection.clearAgentSelectionPreference, setSelected], + () => !!query.request && !query.request.explicit, + [query.request], ); + const updateMentionQuery = query.update; const openMentionPicker = React.useCallback( (cursorPosition: number, preference: MentionPickerMode = null) => { - autocompleteGenerationRef.current += 1; - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - flushedMentionStartIndexRef.current = null; - mentionPickerOriginRef.current = "explicit"; - if (preference === "preserve") { - setMentionStartIndex(cursorPosition); - return; - } - mentionSelection.prepareSelectionPreference(preference); - setMentionQuery(""); - setMentionStartIndex(cursorPosition); - setSelected(0); + query.open(cursorPosition, preference === "first-agent"); }, - [mentionSelection.prepareSelectionPreference, setSelected], + [query.open], ); const extractMentionPubkeysForCurrentMentions = React.useCallback( (text: string): string[] => { @@ -688,7 +779,7 @@ export function useMentions( () => selectedAgentMentionPubkeysRef.current, ).current; const revalidateMentionPubkeys = useAgentMentionRevalidation({ - agentPubkeys: agentIdentityPubkeys, + agentPubkeys: knownAgentPubkeys, getSelectedAgentPubkeys, currentPubkey, eligibilityScope: mentionChannelId @@ -699,6 +790,58 @@ export function useMentions( sharedChannelIds, refetchManagedAgents: managedAgentsQuery.refetch, }); + // Capture installed row identity and revision, never an availability-overlay identity. + const admitMention = ( + suggestion: MentionSuggestion, + cursor: number, + consumerValid: () => boolean, + commit: () => void, + ) => { + const revision = query.getRevision(); + const valid = () => + consumerValid() && + query.getRevision() === revision && + query.read().cursor === cursor && + ((query.isCurrent() && + snapshotSuggestions.some( + (row) => row === rowOwners.current.get(suggestion), + )) || + (!query.request && + defaultChoice.current?.row === suggestion && + defaultChoice.current.revision === revision)) && + canSelectMention(suggestion); + const recipients = suggestion.teamMembers ?? [suggestion]; + const pubkeys = recipients.flatMap((row) => + row.pubkey ? [normalizePubkey(row.pubkey)] : [], + ); + const intendedAgentPubkeys = recipients.flatMap((row) => + row.pubkey && + (("isAgent" in row && row.isAgent) || + knownAgentPubkeys.has(normalizePubkey(row.pubkey))) + ? [normalizePubkey(row.pubkey)] + : [], + ); + admission.begin({ + key: rowOwners.current.get(suggestion) ?? suggestion, + valid, + prepare: () => + revalidateMentionPubkeys(pubkeys, channelId, { + phase: "prepare", + intendedAgentPubkeys, + }), + commit, + }); + }; + const selectMention = ( + suggestion: MentionSuggestion, + cursor: number, + consumerValid: () => boolean, + commit: (edit: AutocompleteEdit) => void, + ) => + admitMention(suggestion, cursor, consumerValid, () => { + const edit = insertMention(suggestion, cursor); + if (edit.insertText) commit(edit); + }); const extractMentionPersonas = React.useCallback( (text: string): PersonaMentionTarget[] => extractMentionPersonasFromMaps( @@ -713,18 +856,7 @@ export function useMentions( ), [activePersonaById, mentionCandidates], ); - const cancelMentionAutocomplete = React.useCallback(() => { - autocompleteGenerationRef.current += 1; - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - flushedMentionStartIndexRef.current = null; - mentionPickerOriginRef.current = null; - mentionSelection.clearAgentSelectionPreference(); - setMentionQuery(null); - setSelected(0); - }, [mentionSelection.clearAgentSelectionPreference, setSelected]); + const cancelMentionAutocomplete = query.cancel; const clearMentions = React.useCallback(() => { cancelMentionAutocomplete(); mentionMapRef.current.clear(); @@ -745,98 +877,75 @@ export function useMentions( setSelectedNames: setSelectedMentionNames, setSelectedAgentNames: setSelectedAgentMentionNames, }); - const handleMentionKeyDown = React.useCallback( - ( - event: React.KeyboardEvent, - // `isCodeContext` is only consulted for Space: inside code the typed - // text must stay literal, so Space is left to the editor. - opts?: { isCodeContext?: () => boolean }, - ): { handled: boolean; suggestion?: MentionSuggestion } => { - const exactMentionSpace = - isPlainSpace(event.nativeEvent) && !opts?.isCodeContext?.(); - if (!isMentionOpen && !exactMentionSpace) return { handled: false }; - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelected((current) => - current < suggestions.length - 1 ? current + 1 : 0, - ); - return { handled: true }; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setSelected((current) => - current > 0 ? current - 1 : suggestions.length - 1, - ); - return { handled: true }; - } - // Shift+Tab is deliberately not a select: it is the keyboard route out - // of the editor — into this overlay's Options controls where the - // composer offers them, otherwise the browser's own backward focus - // move — so those controls stay reachable. - if ( - exactMentionSpace || - (event.key === "Tab" && !event.shiftKey) || - (event.key === "Enter" && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey) - ) { - if (debounceTimerRef.current !== null || exactMentionSpace) { - const flushed = flushMentionDebounce({ - debounceTimerRef, - latestValueRef, - latestCursorRef, - searchableNamesLowerRef, - candidates: mentionCandidatesWithTeams, - activePersonaIds, - agentProvenanceReady: agentDirectoriesReady, - channelType: options?.channelType, - currentPubkey, - ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, - requireExact: exactMentionSpace, - }); - if (exactMentionSpace && flushed?.type !== "match") - return { handled: false }; - event.preventDefault(); - if (flushed?.type === "match") { - flushedMentionStartIndexRef.current = flushed.startIndex; - mentionPickerOriginRef.current = "inline"; - setMentionQuery(null); // reset so dropdown closes - return { handled: true, suggestion: flushed.suggestion }; - } - if (flushed?.type === "no-match") { - setMentionQuery(null); - return { handled: true }; - } - } - event.preventDefault(); - return { handled: true, suggestion: suggestions[mentionSelectedIndex] }; - } - if (event.key === "Escape") { - event.preventDefault(); - cancelMentionAutocomplete(); // full cancel incl. pending debounce - return { handled: true }; - } + const handleMentionKeyDown = ( + event: React.KeyboardEvent, + opts?: { isCodeContext?: () => boolean }, + ): { handled: boolean; suggestion?: MentionSuggestion } => { + if (!isMentionOpen) return { handled: false }; + if (!query.isCurrent()) { + query.cancel(); return { handled: false }; - }, - [ - activePersonaIds, - agentDirectoriesReady, - cancelMentionAutocomplete, - currentPubkey, - isMentionOpen, - mentionCandidatesWithTeams, - mentionSelectedIndex, - options?.channelType, - ownerProfilesQuery.data?.profiles, - profiles, - setSelected, - suggestions, - ], - ); + } + if (event.key === "Escape") { + admission.cancel(); + event.preventDefault(); + query.cancel(); + return { handled: true }; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + admission.cancel(); + mentionSelection.move(event.key === "ArrowDown" ? 1 : -1); + return { handled: true }; + } + const exactSpace = + event.key === " " && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.nativeEvent.isComposing && + !opts?.isCodeContext?.(); + const selectKey = + (event.key === "Tab" && !event.shiftKey) || + (event.key === "Enter" && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey); + if (!exactSpace && !selectKey) return { handled: false }; + let chosen = suggestions[mentionSelectedIndex]; + if (exactSpace) { + // Space remains literal for partial/multi-word/ambiguous names. Unlike + // Tab, it is implicit completion, not a choice of the highlighted row. + const name = mentionQuery?.trim().toLowerCase(); + const exact = suggestions.filter( + (s) => s.displayName.trim().toLowerCase() === name, + ); + if ( + !name || + exact.length !== 1 || + exact[0].hasNameCollision || + searchableNamesLower.some((s) => s.startsWith(`${name} `)) || + userSearchQuery.hasNextPage || + userSearchQuery.isFetching || + !userSearchQuery.isSuccess || + !canSelectMention(exact[0]) + ) + return { handled: false }; + chosen = exact[0]; + } + event.preventDefault(); + return chosen && canSelectMention(chosen) + ? { handled: true, suggestion: chosen } + : { handled: true }; + }; return { + admitMention, + selectMention, + cancelMentionAdmission: admission.cancel, + mentionAdmissionStatus: admission.status, + canSelectMention, cancelMentionAutocomplete, clearMentions, getDefaultAgentSuggestion, @@ -847,13 +956,12 @@ export function useMentions( getMentionDisplayName, handleMentionKeyDown, hasResolvedMembers: members !== undefined, - insertMention, - insertResolvedMention, agentKnownNames: agentHighlightNames, isAgentPubkey, isManagedAgentPubkey, isInlineMentionSelection, isMentionOpen, + isMentionLoading, knownNames: highlightNames, memberPubkeys, mentionSelectedIndex, @@ -862,9 +970,6 @@ export function useMentions( registerMentionPubkey, restoreDraftMentionRefs, suggestions, - fetchMoreSuggestions, - hasMoreSuggestions: Boolean(userSearchQuery.hasNextPage), - isFetchingMoreSuggestions: userSearchQuery.isFetchingNextPage, updateMentionQuery, }; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index f0c5878ab05..b38e8a67723 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -89,6 +89,8 @@ export type AutocompleteEdit = { export type RichTextEditorOptions = { placeholder?: string; onUpdate?: (info: ReturnType) => void; + /** Caret-only updates let completion owners dismiss abandoned queries. */ + onSelectionUpdate?: (info: ReturnType) => void; editable?: boolean; mentionNames?: string[]; agentMentionNames?: string[]; @@ -151,6 +153,7 @@ export type RichTextEditorOptions = { export function useRichTextEditor({ placeholder, onUpdate, + onSelectionUpdate, editable = true, mentionNames, agentMentionNames, @@ -165,6 +168,8 @@ export function useRichTextEditor({ onLinkShortcut, }: RichTextEditorOptions) { const addressedAgentMentionNamesRef = React.useRef([]); + const onSelectionUpdateRef = React.useRef(onSelectionUpdate); + onSelectionUpdateRef.current = onSelectionUpdate; const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; const onSubmitRef = React.useRef(onSubmit); @@ -394,7 +399,10 @@ export function useRichTextEditor({ addKeyboardShortcuts() { return { Enter: ({ editor: ed }) => { - if (isAutocompleteOpen?.current) return false; + // The wrapper owns autocomplete Enter. Suppress splitBlock + // before the event bubbles there, so its current-document + // check sees the query the user actually chose. + if (isAutocompleteOpen?.current) return true; if (!onSubmitRef.current) return false; const fenceResult = handleCodeFenceEnter(ed); @@ -587,6 +595,11 @@ export function useRichTextEditor({ return handler(); }, }, + onSelectionUpdate: ({ editor: ed }) => { + onSelectionUpdateRef.current?.( + buildPreviewUpdate(ed.state.doc, ed.state.selection.anchor), + ); + }, onUpdate: ({ editor: ed }) => { // Keep the hot typing path lightweight. Markdown serialization is // still available through `getMarkdown()` for send/draft boundaries; 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 0b873a4074c..00d92a4ce0f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,3 +1,8 @@ +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"; @@ -32,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 = { @@ -46,7 +58,8 @@ type MentionAutocompleteProps = { * Options controls keeps the overlay mounted. */ composerOwnsFocus: boolean; - onFetchMore?: () => void; + isOpen?: boolean; + isLoading?: boolean; onSelect: (suggestion: MentionSuggestion) => void; lockedAgentPubkeys?: ReadonlySet; onToggleAlwaysAddressAgent?: (suggestion: MentionSuggestion) => void; @@ -86,7 +99,8 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestions, selectedIndex, composerOwnsFocus, - onFetchMore, + isOpen = suggestions.length > 0, + isLoading = false, onSelect, lockedAgentPubkeys, onToggleAlwaysAddressAgent, @@ -101,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); @@ -163,15 +178,6 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ document.removeEventListener("pointerdown", handlePointerDown, true); }, [onDismiss]); - const handleScroll = React.useCallback(() => { - const list = listRef.current; - if (!list || !onFetchMore) return; - - if (list.scrollHeight - list.scrollTop - list.clientHeight < 48) { - onFetchMore(); - } - }, [onFetchMore]); - // Escape from inside the overlay is the keyboard counterpart of pressing // outside it: hand focus back to the editor the overlay belongs to, then // dismiss. Focusing first keeps the composer's focus ownership unbroken, so @@ -192,7 +198,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ [onDismiss], ); - if (!composerOwnsFocus || suggestions.length === 0) { + if (!composerOwnsFocus || !isOpen) { return null; } @@ -217,6 +223,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ ref={rootRef} >
+ {isLoading || suggestions.length === 0 ? ( +
+ {isLoading ? "Loading mentions…" : "No mentions found"} +
+ ) : null} {onKeepMentionedAgentsPinnedChange ? (
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, no behavior of its own — an unprevented mousedown on this surface (its padding, the switch's label) blurs the editor, and the focus gate above would unmount the overlay before the click lands. */} @@ -317,7 +331,6 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ )} data-testid="mention-autocomplete" onMouseDown={(event) => event.preventDefault()} - onScroll={handleScroll} ref={listRef} style={POPOVER_SHADOW_STYLE} > @@ -330,15 +343,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? suggestion.displayName; const hasNameCollision = + suggestion.hasNameCollision || (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; const showAgentProvenanceMarker = showMentionAgentProvenanceMarker( 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) @@ -348,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, ); @@ -373,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/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9b409cb93b5..a5049bf8154 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -55,6 +55,7 @@ import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerUploadError } from "./ComposerUploadError"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; +import { useComposerScrollToBottom } from "./useComposerScrollToBottom"; import { useComposerVoiceNote } from "./useComposerVoiceNote"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker"; @@ -152,6 +153,8 @@ function MessageComposerImpl({ const mentions = useMentions(channelId, undefined, profiles, { channelType, recentMentionPubkeys, + getEditorSnapshot: (): { text: string; cursor: number } => + richText.getPlainTextAndCursor(), }); const channelLinks = useChannelLinks(); const customEmoji = useCustomEmoji(); @@ -271,13 +274,7 @@ function MessageComposerImpl({ ((info: LinkSelectionInfo | null) => void) | null >(null); const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null); - const scrollComposerToBottom = React.useCallback(() => { - window.requestAnimationFrame(() => { - const scrollElement = composerScrollRef.current; - if (!scrollElement) return; - scrollElement.scrollTop = scrollElement.scrollHeight; - }); - }, []); + const scrollComposerToBottom = useComposerScrollToBottom(composerScrollRef); const computedPlaceholder = editTarget ? "Edit your message" : (placeholder ?? @@ -302,6 +299,8 @@ function MessageComposerImpl({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, + onSelectionUpdate: ({ text, cursor }) => + mentions.updateMentionQuery(text, cursor), onUpdate: ({ cursor, linkPreviewContent, text }) => { trackAuthoredContent(text); contentRef.current = text; @@ -439,7 +438,7 @@ function MessageComposerImpl({ if (!replyTarget || composerDisabled) return; richText.focusPreserve(); }, [composerDisabled, replyTarget, richText.focusPreserve]); - useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); + useComposerAutofocus(richText.editor, effectiveDraftKey, composerDisabled); // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). const applyAutocompleteEdit = React.useCallback( @@ -514,7 +513,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; @@ -564,6 +562,7 @@ function MessageComposerImpl({ onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { + mentions.cancelMentionAdmission(); const trimmed = syncComposerContentFromEditor().trim(); // Edit mode if (editTargetRef.current && onEditSaveRef.current) { @@ -683,6 +682,7 @@ function MessageComposerImpl({ media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, + mentions.cancelMentionAdmission, mentions.clearMentions, richText.clearContent, richText.setContent, @@ -907,7 +907,11 @@ function MessageComposerImpl({ {composerLinkPreviews} {addressLockAnnouncement} diff --git a/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx b/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx index a4e82121091..3ccd94d05e1 100644 --- a/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx +++ b/desktop/src/features/messages/ui/MessageComposerAutocompletes.tsx @@ -93,7 +93,8 @@ export function MessageComposerAutocompletes({ onToggleAlwaysAddressAgent={ audienceControlsEnabled ? onToggleAlwaysAddressAgent : undefined } - onFetchMore={mentions.fetchMoreSuggestions} + isOpen={mentions.isMentionOpen} + isLoading={mentions.isMentionLoading} onDismiss={mentions.cancelMentionAutocomplete} onSelect={onMentionSelect} selectedIndex={mentions.mentionSelectedIndex} 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/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index b0272500572..d67313e5ccb 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -24,13 +24,15 @@ afterEach(async () => { after(() => dom.window.close()); test("agent picker preference skips people", async () => { - const { act, renderHook } = await import("@testing-library/react"); + const { renderHook } = await import("@testing-library/react"); const { useMentionSelection } = await import( "@/features/messages/lib/useMentionSelection" ); + const request = { firstAgent: true }; const view = renderHook( - ({ suggestions }) => useMentionSelection(suggestions), - { initialProps: { suggestions: [] } }, + ({ suggestions, ready }) => + useMentionSelection(request, suggestions, ready), + { initialProps: { suggestions: [], ready: false } }, ); const suggestions = [ { displayName: "Alice", pubkey: "person" }, @@ -39,8 +41,7 @@ test("agent picker preference skips people", async () => { { displayName: "Agent Bea", isAgent: true, pubkey: "agent-b" }, ]; - act(() => view.result.current.prepareSelectionPreference("first-agent")); - view.rerender({ suggestions }); + view.rerender({ suggestions, ready: true }); assert.equal(view.result.current.mentionSelectedIndex, 1); }); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 3dbf8f3a884..916fdbc7140 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -43,6 +43,11 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds isInlineMentionSelection: () => false, isMentionOpen: true, openMentionPicker: (...args) => openPickerCalls.push(args), + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -82,7 +87,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds }, ]); assert.equal(cancelCount, 0); - assert.deepEqual(openPickerCalls, [[text.length, "preserve"]]); + assert.deepEqual(openPickerCalls, [[text.length]]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -117,6 +122,11 @@ test("always addressing a new agent delegates the first add for immediate confir getMentionDisplayName: () => "Agent Ada", isInlineMentionSelection: () => false, isMentionOpen: false, + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, }, onAddressAgentMention: (value) => addressedSuggestions.push(value), @@ -156,6 +166,11 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -216,8 +231,17 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, insertMention: () => ({ replaceFromOffset: 5, replaceToOffset: 5, @@ -275,8 +299,17 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, insertMention: () => ({ replaceFromOffset: 5, replaceToOffset: 6, @@ -342,6 +375,11 @@ test("selecting a human mention never changes automatic addressing", async () => audienceScope: "channel-scope", mentions: { getMentionDisplayName: () => "Alice", + isInlineMentionSelection: () => true, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 3, @@ -394,6 +432,11 @@ test("restoring a multi-word automatic mention into an empty composer focuses af mentions: { getDraftMentionRefs: () => [], getMentionDisplayName: () => "claude code", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -443,6 +486,11 @@ test("restoring before authored text preserves its selection", async () => { mentions: { getDraftMentionRefs: () => [], getMentionDisplayName: () => "Morgarita", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, }, onPulseAddressLock: () => {}, @@ -497,6 +545,11 @@ test("restoring an existing automatic mention re-registers its agent chip", asyn ] : [], getMentionDisplayName: () => "claude code", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -638,8 +691,17 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, insertMention: () => ({ replaceFromOffset: 5, replaceToOffset: 5, @@ -704,8 +766,17 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, ], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 0, @@ -818,6 +889,11 @@ test("restoring after an agent rename keeps the existing automatic mention", asy { displayName: oldName, pubkey: "agent-pubkey", isAgent: true }, ], getMentionDisplayName: () => displayName, + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -899,6 +975,11 @@ test("automatic mention insertion and restoration use the registered collision-s snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]), getMentionDisplayName: (pubkey) => [...bindings].find(([, key]) => key === pubkey)?.[0] ?? "carl", + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (name, pubkey) => { const label = selectedMentionLabel(name, pubkey, bindings); bindings.set(label, pubkey); @@ -998,6 +1079,11 @@ test("inverse deletion and toggle preserve B and exclude A from the composed sen snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]), getMentionDisplayName: (key) => [...bindings].find(([, k]) => k === key)?.[0], + canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (name, key) => { const label = selectedMentionLabel(name, key, bindings); bindings.set(label, key); @@ -1077,6 +1163,7 @@ test("implicit prefix removal uses the present exact label rather than a stale a audience: { pubkeys: [key], excludePubkey: () => {} }, audienceScope: "channel", mentions: { + cancelMentionAdmission: () => {}, getDraftMentionRefs: () => [ { displayName: "Historical Scout", pubkey: key, isAgent: true }, ], @@ -1097,3 +1184,43 @@ test("implicit prefix removal uses the present exact label rather than a stale a act(() => result.current.removeAddressedAgent(key)); assert.equal(text, "hello"); }); + +test("rejected stale selection never pins, tracks, announces or edits", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const effects = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => effects.push("edit"), + audience: { pubkeys: [], addPubkey: () => effects.push("audience") }, + audienceScope: "room", + mentions: { + getMentionDisplayName: () => "Scout", + isInlineMentionSelection: () => true, + selectMention(_row, _cursor, valid, commit) { + const edit = this.insertMention(); + if (valid() && edit.insertText) commit(edit); + }, + insertMention: () => ({ + replaceFromOffset: 1, + replaceToOffset: 1, + insertText: "", + }), + }, + onAutoPinAgentMention: () => effects.push("pin"), + onPulseAddressLock: () => effects.push("pulse"), + richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) }, + }), + ); + act(() => + result.current.selectMentionSuggestion({ + pubkey: "a".repeat(64), + displayName: "Scout", + isAgent: true, + }), + ); + assert.deepEqual(effects, []); + assert.equal(result.current.announcement, ""); +}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index cd501479d27..26709b9e66b 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -1,3 +1,4 @@ +import { useMentionAdmissionEditor } from "../lib/useMentionAdmissionEditor"; import * as React from "react"; import { mentionOccurrences } from "@/shared/lib/mentionOccurrences"; @@ -87,10 +88,15 @@ export function useAgentAddressLockPicker({ profiles?: UserProfileLookup; richText: UseRichTextEditorResult; }) { + useMentionAdmissionEditor(richText.editor, mentions.cancelMentionAdmission); const lockedAgentPubkeys = React.useMemo( () => new Set(audience.pubkeys), [audience.pubkeys], ); + // A retained callback belongs to one audience visit, including A -> B -> A. + const scopeOwner = React.useMemo(() => ({ audienceScope }), [audienceScope]); + const currentAudience = React.useRef({ scopeOwner, lockedAgentPubkeys }); + currentAudience.current = { scopeOwner, lockedAgentPubkeys }; const unpinnedAgentPubkeysRef = React.useRef(new Set()); const unpinnedAudienceScopeRef = React.useRef(audienceScope); if (unpinnedAudienceScopeRef.current !== audienceScope) { @@ -182,7 +188,13 @@ export function useAgentAddressLockPicker({ const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); - if (!audienceScope || !normalized) return; + if ( + !audienceScope || + !normalized || + currentAudience.current.scopeOwner !== scopeOwner + ) + return; + mentions.cancelMentionAdmission(); unpinnedAgentPubkeysRef.current.add(normalized); const excludePubkey = audience.excludePubkey ?? audience.removePubkey; excludePubkey(normalized); @@ -216,6 +228,8 @@ export function useAgentAddressLockPicker({ }, [ applyAutocompleteEdit, + mentions.cancelMentionAdmission, + scopeOwner, audience.excludePubkey, audience.removePubkey, audienceScope, @@ -246,87 +260,107 @@ export function useAgentAddressLockPicker({ const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return; + if ( + !audienceScope || + !pubkey || + !suggestion.isAgent || + currentAudience.current.scopeOwner !== scopeOwner + ) + return; - if (lockedAgentPubkeys.has(pubkey)) { + const refreshPicker = () => { + if (mentions.isMentionOpen) { + const { text, cursor } = richText.getPlainTextAndCursor(); + if (mentions.isInlineMentionSelection()) { + const activeMention = detectPrefixQuery("@", text, cursor, [ + suggestion.displayName.toLowerCase(), + ]); + const queryStart = Math.max( + 0, + Math.min( + activeMention?.startIndex ?? mentions.mentionStartIndex, + text.length, + ), + ); + applyAutocompleteEdit({ + replaceFromOffset: queryStart, + replaceToOffset: Math.max( + queryStart, + Math.min(cursor, text.length), + ), + insertText: "", + }); + mentions.openMentionPicker(queryStart); + } else { + mentions.openMentionPicker(cursor); + } + } + }; + if (currentAudience.current.lockedAgentPubkeys.has(pubkey)) { + mentions.cancelMentionAdmission(); removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); - } else { - unpinnedAgentPubkeysRef.current.delete(pubkey); - const label = - mentions.registerMentionPubkey(suggestion.displayName, pubkey, { - isAgent: true, - }) ?? suggestion.displayName; - const { text } = richText.getPlainTextAndCursor(); - if ( - !mentions - .getDraftMentionRefs(text) - .some( - (ref) => - normalizePubkey(ref.pubkey) === pubkey && - ref.displayName === label, - ) - ) { - const insertedText = `@${label} `; - onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]); - applyAutocompleteEdit({ - replaceFromOffset: 0, - replaceToOffset: 0, - insertText: insertedText, - preserveSelection: text.length > 0, - reassertMentionCaret: false, - }); - } - trackMentionAddressedAgent(pubkey); - if (onAddressAgentMention) { - onAddressAgentMention(suggestion); - } else { - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); - } - setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); - } - - if (mentions.isMentionOpen) { - const { text, cursor } = richText.getPlainTextAndCursor(); - if (mentions.isInlineMentionSelection()) { - const activeMention = detectPrefixQuery("@", text, cursor, [ - suggestion.displayName.toLowerCase(), - ]); - const queryStart = Math.max( - 0, - Math.min( - activeMention?.startIndex ?? mentions.mentionStartIndex, - text.length, - ), - ); - applyAutocompleteEdit({ - replaceFromOffset: queryStart, - replaceToOffset: Math.max( - queryStart, - Math.min(cursor, text.length), - ), - insertText: "", - }); - mentions.openMentionPicker(queryStart, "preserve"); - } else { - mentions.openMentionPicker(cursor, "preserve"); - } + refreshPicker(); + return; } + const { cursor } = richText.getPlainTextAndCursor(); + mentions.admitMention( + suggestion, + cursor, + () => currentAudience.current.scopeOwner === scopeOwner, + () => { + unpinnedAgentPubkeysRef.current.delete(pubkey); + const label = + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }) ?? suggestion.displayName; + const { text } = richText.getPlainTextAndCursor(); + if ( + !mentions + .getDraftMentionRefs(text) + .some( + (ref) => + normalizePubkey(ref.pubkey) === pubkey && + ref.displayName === label, + ) + ) { + const insertedText = `@${label} `; + onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]); + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: text.length > 0, + reassertMentionCaret: false, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } + setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + refreshPicker(); + }, + ); }, [ applyAutocompleteEdit, audience.addPubkey, audienceScope, - lockedAgentPubkeys, mentions.getDraftMentionRefs, mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, mentions.registerMentionPubkey, + mentions.admitMention, + mentions.cancelMentionAdmission, + scopeOwner, onAddressAgentMention, onImplicitPrefixInserted, onPulseAddressLock, @@ -338,39 +372,49 @@ export function useAgentAddressLockPicker({ const selectMentionSuggestion = React.useCallback( (suggestion: MentionSuggestion) => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (suggestion.isAgent && pubkey && audienceScope) { - const { cursor } = richText.getPlainTextAndCursor(); - const wasUnpinned = - !lockedAgentPubkeys.has(pubkey) && - unpinnedAgentPubkeysRef.current.has(pubkey); - if (mentions.isInlineMentionSelection() || wasUnpinned) { - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion, { - reinstateExcluded: !wasUnpinned, - }); - return; - } + if (currentAudience.current.scopeOwner !== scopeOwner) return; + const { cursor } = richText.getPlainTextAndCursor(); + const wasInlineSelection = mentions.isInlineMentionSelection(); + mentions.selectMention( + suggestion, + cursor, + () => currentAudience.current.scopeOwner === scopeOwner, + (edit) => { + const pubkey = normalizePubkey(suggestion.pubkey ?? ""); + if (suggestion.isAgent && pubkey && audienceScope) { + const wasUnpinned = + !lockedAgentPubkeys.has(pubkey) && + unpinnedAgentPubkeysRef.current.has(pubkey); + if (wasInlineSelection || wasUnpinned) { + applyAutocompleteEdit(edit); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion, { + reinstateExcluded: !wasUnpinned, + }); + return; + } - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (!lockedAgentPubkeys.has(pubkey)) { - trackMentionAddressedAgent(pubkey); - if (onAddressAgentMention) { - onAddressAgentMention(suggestion); - } else { - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + applyAutocompleteEdit(edit); + if (!lockedAgentPubkeys.has(pubkey)) { + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } + setAnnouncement( + `Automatically mentioning ${suggestion.displayName}`, + ); + } else { + onPulseAddressLock(pubkey); + } + return; } - setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); - } else { - onPulseAddressLock(pubkey); - } - return; - } - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + applyAutocompleteEdit(edit); + }, + ); }, [ applyAutocompleteEdit, @@ -378,7 +422,8 @@ export function useAgentAddressLockPicker({ audienceScope, lockedAgentPubkeys, mentions.isInlineMentionSelection, - mentions.insertMention, + mentions.selectMention, + scopeOwner, onAddressAgentMention, onAutoPinAgentMention, onPulseAddressLock, @@ -487,7 +532,7 @@ export function useAgentAddressLockPicker({ ); return { - announcement, + announcement: mentions.mentionAdmissionStatus || announcement, lockedAgents, lockedAgentPubkeys, removeAddressedAgent, diff --git a/desktop/src/features/messages/ui/useComposerScrollToBottom.ts b/desktop/src/features/messages/ui/useComposerScrollToBottom.ts new file mode 100644 index 00000000000..8cb346730c9 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerScrollToBottom.ts @@ -0,0 +1,14 @@ +import * as React from "react"; + +/** Scroll the current composer element after its next layout frame. */ +export function useComposerScrollToBottom( + composerScrollRef: React.RefObject, +) { + return React.useCallback(() => { + window.requestAnimationFrame(() => { + const scrollElement = composerScrollRef.current; + if (!scrollElement) return; + scrollElement.scrollTop = scrollElement.scrollHeight; + }); + }, [composerScrollRef]); +} 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.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs index 192fe5b7e6d..acf0725e23f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -3,11 +3,31 @@ import test from "node:test"; import { formatMessageSendError, + formatMentionSendError, getErrorMessage, mergeMentionRecipients, mentionRevalidationOptions, } from "./useMentionSendFlow.helpers.ts"; +import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts"; + +test("mention send errors preserve authorization guidance and generic failure details", () => { + const denied = new AgentMentionAuthorizationError(); + assert.equal(formatMentionSendError(denied), denied.message); + assert.equal( + formatMentionSendError(new Error("relay rejected")), + "Message failed to send: relay rejected", + ); + assert.equal( + formatMentionSendError("upload rejected"), + "Message failed to send: upload rejected", + ); + assert.equal( + formatMentionSendError({}), + "Message failed to send: Unknown error", + ); +}); + test("formatMessageSendError preserves the publication failure", () => { assert.equal( formatMessageSendError(new Error("relay rejected voice note")), @@ -61,3 +81,15 @@ test("revalidation carries captured and prepared agent keys independently of the }, ); }); + +// The extracted freshness prefix keeps the root's send-error policy unchanged. +test("formatMentionSendError preserves authorization guidance and other failures", async () => { + const { AgentMentionAuthorizationError } = await import( + "../lib/agentMentionRevalidation.ts" + ); + const authorization = new AgentMentionAuthorizationError(); + assert.equal(formatMentionSendError(authorization), authorization.message); + for (const error of [new Error("relay rejected"), "upload rejected", null]) { + assert.equal(formatMentionSendError(error), formatMessageSendError(error)); + } +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index d70186bc446..2425beb2a4d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -1,4 +1,7 @@ -import type { MentionRevalidationOptions } from "@/features/messages/lib/agentMentionRevalidation"; +import { + AgentMentionAuthorizationError, + type MentionRevalidationOptions, +} from "@/features/messages/lib/agentMentionRevalidation"; import type { ManagedAgent } from "@/shared/api/types"; import { type ImetaMedia, @@ -110,6 +113,13 @@ export function formatMessageSendError(error: unknown) { return `Message failed to send: ${getErrorMessage(error, "Unknown error")}`; } +/** Preserve authorization guidance verbatim; contextualize other send failures. */ +export function formatMentionSendError(error: unknown) { + return error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error); +} + export function uniqueNormalizedPubkeys(pubkeys: Iterable) { return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); } 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/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 4585a2360a1..ff6a2a9bb53 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -31,7 +31,7 @@ import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - formatMessageSendError, + formatMentionSendError, getErrorMessage, mentionRevalidationOptions, withoutInvitingRecipients, @@ -44,7 +44,6 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -import { AgentMentionAuthorizationError } from "@/features/messages/lib/agentMentionRevalidation"; import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; export function useMentionSendFlow({ @@ -650,11 +649,7 @@ export function useMentionSendFlow({ await finishSend(uploaded, signal); } catch (error) { restoreComposerAfterFailure(); - toast.error( - error instanceof AgentMentionAuthorizationError - ? error.message - : formatMessageSendError(error), - ); + toast.error(formatMentionSendError(error)); } finally { settleUpload(); } @@ -682,11 +677,7 @@ export function useMentionSendFlow({ await finishSend([]); } catch (error) { restoreComposerAfterFailure(); - toast.error( - error instanceof AgentMentionAuthorizationError - ? error.message - : formatMessageSendError(error), - ); + toast.error(formatMentionSendError(error)); } } } catch (error) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index fc35fb7c133..a18a3cc620f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -341,6 +341,8 @@ type E2eConfig = { /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; agentListDelayMs?: number; + /** Hold initial directory reads until explicit test release. */ + deferAgentList?: boolean; agentMemory?: RawAgentMemoryListing | Record; addChannelMembersDelayMs?: number; /** Sequenced add-member failures. A string fails that call; null succeeds. */ @@ -931,6 +933,7 @@ type RawManagedAgent = { pubkey: string; name: string; persona_id: string | null; + team_id?: string | null; /** Record-level harness/runtime pin (`null` when inheriting from the persona). */ runtime: string | null; relay_url: string; @@ -1452,6 +1455,7 @@ declare global { slotId: string; }) => unknown; __BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void; + __BUZZ_E2E_RELEASE_AGENT_LIST__?: () => void; __BUZZ_E2E_QUERY_CLIENT__?: { invalidateQueries: (filters: { queryKey: readonly unknown[]; @@ -1841,6 +1845,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, + team_id: agent.team_id ?? null, runtime: agent.runtime ?? null, relay_url: agent.relay_url, acp_command: agent.acp_command, @@ -8245,7 +8250,10 @@ async function handleGetFeed( }; } +let mockAgentListGate: Promise | null = null; + async function delayAgentList(config: E2eConfig | undefined) { + await mockAgentListGate; const agentListDelayMs = config?.mock?.agentListDelayMs ?? 0; if (agentListDelayMs > 0) { await new Promise((resolve) => { @@ -9380,6 +9388,7 @@ async function handleCreateManagedAgent( input: { name: string; personaId?: string; + teamId?: string; relayUrl?: string; acpCommand?: string; agentCommand?: string; @@ -9456,6 +9465,7 @@ async function handleCreateManagedAgent( pubkey, name, persona_id: args.input.personaId ?? null, + team_id: args.input.teamId ?? null, // Create never pins a harness id — the record inherits from the persona. runtime: null, relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL, @@ -11236,6 +11246,12 @@ export function maybeInstallE2eTauriMocks() { : null; resetMockRelayMembers(config); resetMockRelayAgents(config); + window.__BUZZ_E2E_RELEASE_AGENT_LIST__?.(); + mockAgentListGate = config.mock?.deferAgentList + ? new Promise((resolve) => { + window.__BUZZ_E2E_RELEASE_AGENT_LIST__ = resolve; + }) + : null; resetMockManagedAgents(config); resetMockPersonas(config); resetMockTeams(config); 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/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 57b35db386e..c0979fc84e4 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -476,6 +476,116 @@ test.describe("community rail", () => { .toBe(COMMUNITY_B.id); }); + test("community round trip clears mention ranking for the same viewer and channel", async ({ + page, + }) => { + const first = "11".repeat(32); + const chosen = "22".repeat(32); + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await installMockBridge( + page, + { + managedAgents: [], + relayAgents: [first, chosen].map((pubkey) => ({ + pubkey, + name: "Scout", + ownerPubkey: OWNER_PUBKEY, + respondTo: "anyone", + status: "offline", + channelNames: ["general"], + })), + searchProfiles: [first, chosen].map((pubkey) => ({ + pubkey, + displayName: "Scout", + isAgent: true, + })), + }, + { skipCommunitySeed: true }, + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.evaluate( + async ({ channelId, keys }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: keys, + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }, + { channelId, keys: [first, chosen] }, + ); + const input = page.getByTestId("message-input"); + const rows = page.locator("[data-mention-suggestion-index]"); + const rowIds = () => + rows.evaluateAll((items) => + items.map((row) => row.getAttribute("data-testid")), + ); + const baseline = [first, chosen].map((key) => `mention-suggestion-${key}`); + await input.fill("@Scout"); + await expect.poll(rowIds).toEqual(baseline); + await input.press("ArrowDown"); + await input.press("Tab"); + await expect(input).toHaveText("@Scout "); + await page.keyboard.type("ranking choice"); + await page.getByTestId("send-message").click(); + const publication = (content: string) => + page.evaluate( + (text) => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.content === text) + .map((event) => ({ + viewer: ( + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["identity"]) as + | { data?: { pubkey: string } } + | undefined + )?.data?.pubkey, + channel: event.tags.find((tag) => tag[0] === "h")?.[1], + recipients: event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + })), + content, + ); + await expect + .poll(() => publication("@Scout ranking choice")) + .toEqual([ + { viewer: OWNER_PUBKEY, channel: channelId, recipients: [chosen] }, + ]); + // Positive control: this exact choice really affects the next snapshot. + await input.fill("@Scout"); + await expect.poll(rowIds).toEqual([...baseline].reverse()); + await input.press("Escape"); + await input.fill(""); + + // Real rail navigation remounts the community without reloading the page. + for (const community of [COMMUNITY_B, COMMUNITY_A]) { + await page.getByTestId(`community-rail-button-${community.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${community.id}`), + ).toHaveAttribute("aria-current", "true"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + } + // The identity query used by useMentions and the publication channel bind + // the returned composer to the original scope; + // changing viewer/channel must not masquerade as clearing its history. + await input.fill("ranking returned"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => publication("ranking returned")) + .toEqual([{ viewer: OWNER_PUBKEY, channel: channelId, recipients: [] }]); + await input.fill("@Scout"); + await expect.poll(rowIds).toEqual(baseline); + }); + test("community switch cancels a send after its link preview settles", async ({ page, }) => { diff --git a/desktop/tests/e2e/composer-autofocus.spec.ts b/desktop/tests/e2e/composer-autofocus.spec.ts new file mode 100644 index 00000000000..9542544f5af --- /dev/null +++ b/desktop/tests/e2e/composer-autofocus.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +declare global { + interface Window { + __AUTOFOCUS_ORDER__: { + held: number; + delivered: number; + release: () => void; + }; + } +} + +for (const claimed of [true, false]) { + test(`late automatic focus ${claimed ? "respects an open native menu" : "focuses an unclaimed composer"}`, async ({ + page, + }) => { + await page.addInitScript(() => { + const nativeRAF = window.requestAnimationFrame.bind(window); + const held: FrameRequestCallback[] = []; + const state = { + held: 0, + delivered: 0, + release: () => { + for (const callback of held.splice(0)) + nativeRAF((time) => { + callback(time); + state.delivered++; + }); + }, + }; + window.__AUTOFOCUS_ORDER__ = state; + window.requestAnimationFrame = (callback) => { + // Source-bound to scheduleComposerAutofocus's commit (selection + + // scroll). Hold the genuine callback only when the browser delivers it; + // other frames, including Tiptap explicit focus, are forwarded unchanged. + const source = callback.toString(); + const automatic = + source.includes(".state.tr.setSelection(") && + source.includes(".commands.scrollIntoView()"); + return nativeRAF((time) => { + if (automatic) { + held.push(callback); + state.held++; + } else callback(time); + }); + }; + }); + await installMockBridge(page, { + windowLabel: "huddle-11111111-1111-4111-8111-111111111111", + ttsSettings: { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:vera"], + }, + huddle: { + parentChannelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ephemeralChannelId: "11111111-1111-4111-8111-111111111111", + members: [ + { pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }, + { pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" }, + ], + ttsEnabled: true, + }, + }); + await page.goto("/"); + await expect + .poll(() => page.evaluate(() => window.__AUTOFOCUS_ORDER__.held)) + .toBeGreaterThan(0); + const editor = page.getByTestId("message-input"); + await expect(editor).not.toBeFocused(); + const trigger = page.getByRole("button", { + name: "Voice settings for alice", + }); + const menu = page.locator( + '[data-testid="huddle-agent-voice-menu-content"][data-state="open"]', + ); + if (claimed) { + await trigger.click(); + await expect(menu).toBeVisible(); + await expect(menu.getByTestId("huddle-agent-tts-toggle")).toBeFocused(); + } + await page.evaluate(() => window.__AUTOFOCUS_ORDER__.release()); + await expect + .poll(() => page.evaluate(() => window.__AUTOFOCUS_ORDER__.delivered)) + .toBeGreaterThan(0); + if (claimed) { + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await expect(menu).toBeVisible(); + await expect(editor).not.toBeFocused(); + await menu.getByTestId("huddle-agent-tts-toggle").click(); + await expect( + menu.getByTestId("huddle-agent-tts-toggle"), + ).not.toBeChecked(); + await page.keyboard.press("Escape"); + await editor.click(); + await expect(editor).toBeFocused(); + await editor.fill("explicit typing still works"); + await expect(editor).toHaveText("explicit typing still works"); + } else { + await expect(editor).toBeFocused(); + } + }); +} diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 25d3cbaf9ae..fc188b48edd 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -307,7 +307,26 @@ for (const format of [ await input.pressSequentially("before"); await input.press("Shift+Enter"); await applyCaretFormat(page, format.label); - await input.pressSequentially("inside"); + // Toolbar focus synchronizes the new caret on a later animation frame. + // Locator typing would focus the DOM itself, reviving the old caret. + await expect(input).toBeFocused(); + await expect + .poll(() => + input.evaluate((element, selector) => { + const block = element.querySelector(`:scope > ${selector}`); + const selection = window.getSelection(); + return ( + document.activeElement === element && + !!selection?.isCollapsed && + !!selection.anchorNode && + !!selection.focusNode && + !!block?.contains(selection.anchorNode) && + block.contains(selection.focusNode) + ); + }, format.selector), + ) + .toBe(true); + await page.keyboard.type("inside"); await expect(input.locator(":scope > p").first()).toHaveText("before"); await expect(input.locator(`:scope > ${format.selector}`)).toHaveText( diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts new file mode 100644 index 00000000000..0479bf80ae0 --- /dev/null +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -0,0 +1,281 @@ +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"); + await page.evaluate( + async ({ keys, channelId }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: keys, + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }, + { keys, channelId: GENERAL }, + ); +} +test.use({ viewport: { width: 1280, height: 900 } }); + +test("collision distinction, deliberate key choice, and exact publication", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [], + relayAgents: [ + { + pubkey: A, + name: "Scout", + ownerPubkey: OWNER, + respondTo: "anyone", + status: "offline", + }, + { + pubkey: B, + name: "Scout", + ownerPubkey: "aa".repeat(32), + respondTo: "anyone", + status: "online", + channelNames: ["general"], + }, + ], + searchProfiles: [A, B].map((pubkey) => ({ + pubkey, + displayName: "Scout", + isAgent: true, + })), + }); + await page.goto("/"); + await seedMembers(page, [A, B]); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@Scout"); + await expect(page.getByTestId(`mention-suggestion-${A}`)).toBeVisible(); + const rowIds = await page + .locator("[data-mention-suggestion-index]") + .evaluateAll((rows) => rows.map((row) => row.getAttribute("data-testid"))); + expect(rowIds).toEqual([ + `mention-suggestion-${A}`, + `mention-suggestion-${B}`, + ]); + const first = rowIds[1]?.endsWith(A) ? A : B; + const second = first === A ? B : A; + await input.press("ArrowDown"); + // Membership/presence changes affect the next request, not the visible order. + await page.evaluate(() => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + setQueryData: ( + key: string[], + update: (agents: { status: string }[]) => { status: string }[], + ) => void; + }; + client.setQueryData(["relay-agents"], (agents) => + [...agents].reverse().map((agent) => ({ + ...agent, + status: agent.status === "online" ? "offline" : "online", + })), + ); + }); + await page.waitForTimeout(200); + await expect + .poll(() => + page + .locator("[data-mention-suggestion-index]") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ), + ) + .toEqual(rowIds); + await input.press("Tab"); + await expect(input).toHaveText("@Scout "); + await page.keyboard.type("hello"); + const content = "@Scout hello"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + (content) => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.content === content) + .map((event) => + event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]), + ), + content, + ), + ) + .toEqual([[first]]); + await input.fill("@Scout"); + await expect + .poll(() => + page + .locator("[data-mention-suggestion-index]") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-testid")), + ), + ) + .toEqual([`mention-suggestion-${first}`, `mention-suggestion-${second}`]); + await capture(page, "next-open-ranking"); +}); + +test("Escape discards delayed picker results across navigation", async ({ + page, +}) => { + await installMockBridge(page, { agentListDelayMs: 1500 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill("@bo"); + await expect(page.getByTestId("mention-autocomplete-layer")).toContainText( + "Loading", + ); + await input.press("Escape"); + await expect(page.getByTestId("mention-autocomplete-layer")).toBeHidden(); + // Await the actual delayed request settling, not an arbitrary quiet period. + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.status, + ), + ) + .toBe("success"); + await expect(page.getByTestId("mention-autocomplete-layer")).toBeHidden(); + await expect(input).toHaveText("@bo"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + 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 d430231bda5..bb73b55440a 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -60,6 +60,35 @@ function autocomplete(page: import("@playwright/test").Page) { .getByTestId("mention-autocomplete"); } +async function waitForCompleteMentionSearch( + page: import("@playwright/test").Page, + query: string, +) { + await expect + .poll(() => + page.evaluate((query) => { + const state = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "user-search", + "infinite", + query, + 50, + ]) as + | { + status: string; + fetchStatus: string; + data?: { pages: { nextCursor: string | null }[] }; + } + | undefined; + return ( + state?.status === "success" && + state.fetchStatus === "idle" && + state.data?.pages.at(-1)?.nextCursor === null + ); + }, query), + ) + .toBe(true); +} + async function readCommandLog(page: import("@playwright/test").Page) { return page.evaluate(() => { return ( @@ -84,6 +113,69 @@ async function readCommandPayloadLog(page: import("@playwright/test").Page) { }); } +// AppShell defers usePresenceSession until startup is ready. Observe its +// initial online sign_event entry before creating the raw query, not a timer +// or cached presence lookup. This fences that signing count only: it does not +// establish signing completion, relay delivery, or immunity to later heartbeats. +async function waitForInitialPresenceSigning( + page: import("@playwright/test").Page, +) { + await expect + .poll( + async () => + (await readCommandPayloadLog(page)).some(({ command, payload }) => { + const event = payload as { + kind?: number; + content?: string; + tags?: string[][]; + } | null; + return ( + command === "sign_event" && + event?.kind === 20001 && + event.content === "online" && + Array.isArray(event.tags) && + event.tags.length === 0 + ); + }), + { + message: + "initial online presence sign_event entered before mention probe", + }, + ) + .toBe(true); +} + +// Capture both logs in one browser turn so diagnostics describe the exact +// unfiltered command snapshot used by the assertions, including unsigned events. +async function captureMentionCommandBoundary( + page: import("@playwright/test").Page, + phase: "baseline" | "final", +) { + const snapshot = await page.evaluate(() => ({ + capturedAt: Date.now(), + commands: [...(window.__BUZZ_E2E_COMMANDS__ ?? [])], + payloads: window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + })); + const counts: Record = { + add_channel_members: 0, + start_managed_agent: 0, + attach_managed_agent: 0, + sync_agents_to_active_huddle: 0, + send_channel_message: 0, + sign_event: 0, + revalidate_relay_agents: 0, + list_relay_agents: 0, + }; + for (const command of snapshot.commands) { + counts[command] = (counts[command] ?? 0) + 1; + } + await test.info().attach(`mention-commands-${phase}`, { + body: JSON.stringify({ phase, counts, ...snapshot }), + contentType: "application/json", + }); + return snapshot.commands; +} + async function readOutgoingMentionPubkeys( page: import("@playwright/test").Page, content: string, @@ -409,8 +501,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(); @@ -642,6 +734,7 @@ test("Space inside a code block leaves an exact agent name literal", async ({ await expect(input.locator("pre")).toBeVisible(); await page.keyboard.type("deploy @ALICE"); + await waitForCompleteMentionSearch(page, "alice"); await page.keyboard.press(" "); await page.keyboard.type("now"); @@ -666,6 +759,7 @@ test("Space inside an inline code span leaves an exact agent name literal", asyn // backticks from the text the mention pipeline reads. await page.keyboard.type("run `@ALICE`"); await expect(input.locator("code")).toHaveText("@ALICE"); + await waitForCompleteMentionSearch(page, "alice"); await page.keyboard.press(" "); await page.keyboard.type("now"); @@ -679,27 +773,52 @@ test("Space inside an inline code span leaves an exact agent name literal", asyn .toEqual([]); }); -test("Space still resolves an exact agent name typed after a code span", async ({ - page, -}) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - const input = page.getByTestId("message-input"); - await input.click(); - await page.keyboard.type("run `deploy` @ALICE"); - await page.keyboard.press(" "); - await page.keyboard.type("now"); - - const content = "run `deploy` @alice now"; - await expect(input).toHaveText("run deploy @alice now"); - - await page.getByTestId("send-message").click(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, content)) - .toContain(TEST_IDENTITIES.alice.pubkey); -}); +for (const separator of [" ", "\u00a0"]) { + test(`Space still resolves an exact agent name typed after a code span (${separator === " " ? "space" : "NBSP"})`, async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.type(`run \`deploy\`${separator}@ALICE`); + await waitForCompleteMentionSearch(page, "alice"); + await expect(input.locator("code")).toHaveText("deploy"); + await expect( + autocomplete(page).getByText("alice", { exact: true }), + ).toBeVisible(); + await page.keyboard.press(" "); + await page.keyboard.type("now"); + + await expect(input).toHaveText("run deploy @alice now"); + + await page.getByTestId("send-message").click(); + // Chromium may author NBSP after the code mark. Require the full signed + // body (including its code mark and single separator), not an ASCII-only + // lookup that reports null even when the exact recipient was published. + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_SIGNED_EVENTS__ + ?.filter((event) => event.kind === 9) + .map((event) => ({ + content: event.content, + recipients: event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + })), + ), + ) + .toEqual([ + { + content: expect.stringMatching(/^run `deploy`[ \u00a0]@alice now$/), + recipients: [TEST_IDENTITIES.alice.pubkey], + }, + ]); + }); +} test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, @@ -804,7 +923,11 @@ test("blocks non-participant persona mentions in DM threads", async ({ .getByTestId("mention-autocomplete") .locator("button", { hasText: "Fizz" }), ).toBeVisible(); - await input.press("Enter"); + await threadPanel + .getByTestId("mention-autocomplete") + .locator("button", { hasText: "Fizz" }) + .click(); + await expect(input).toHaveText("Ask @Fizz "); await page.keyboard.type(" in this thread"); const baselineCommands = await readCommandLog(page); @@ -816,6 +939,9 @@ test("blocks non-participant persona mentions in DM threads", async ({ ), ).toBeVisible(); const commands = await readCommandLog(page); + expect(commandCount(await readCommandLog(page), "sign_event")).toBe( + commandCount(baselineCommands, "sign_event"), + ); expect(commandCount(commands, "create_managed_agent")).toBe( commandCount(baselineCommands, "create_managed_agent"), ); @@ -856,14 +982,7 @@ test("defers agent mentions until DM members finish loading", async ({ const threadPanel = page.getByTestId("message-thread-panel"); const input = threadPanel.getByTestId("message-input"); - await input.fill("Ask @ali"); - await expect( - threadPanel - .getByTestId("mention-autocomplete") - .locator("button", { hasText: "alice" }), - ).toBeVisible(); - await input.press("Enter"); - await page.keyboard.type(" before members resolve"); + await input.fill("Ask @alice before members resolve"); const baselineCommands = await readCommandLog(page); await threadPanel.getByTestId("send-message").click(); @@ -871,6 +990,9 @@ test("defers agent mentions until DM members finish loading", async ({ page.getByText(DM_THREAD_MEMBERS_LOADING_ERROR_TEXT).first(), ).toBeVisible(); await page.mouse.move(0, 0); + expect(commandCount(await readCommandLog(page), "sign_event")).toBe( + commandCount(baselineCommands, "sign_event"), + ); expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); @@ -885,8 +1007,12 @@ test("defers agent mentions until DM members finish loading", async ({ expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); - await expect(input).toHaveText("@alice "); + // A one-time mention does not opt into automatic addressing after send. + await expect(input).toHaveText(""); await expect(threadPanel).toContainText("before members resolve"); + expect( + await readOutgoingMentionPubkeys(page, "Ask @alice before members resolve"), + ).toEqual([TEST_IDENTITIES.alice.pubkey]); }); test("autocomplete filters managed-agent suggestions as user types", async ({ @@ -1297,7 +1423,8 @@ test("selecting a persona mention creates a channel agent before sending", async await expect(fizzRow.getByTestId("mention-agent-icon")).toBeVisible(); await expect(fizzRow.getByText("agent")).toBeVisible(); await expect(fizzRow.getByText("not in channel")).toBeVisible(); - await input.press("Enter"); + await fizzRow.click(); + await expect(input).toHaveText("Ask @Fizz "); await page.keyboard.type(" for a hand"); const composerChip = input.locator(".agent-mention-highlight", { @@ -1382,7 +1509,8 @@ test("selecting a persona mention reuses an existing persona agent", async ({ const dropdown = autocomplete(page); const fizzRow = dropdown.locator("button", { hasText: "Fizz" }); await expect(fizzRow).toBeVisible(); - await input.press("Enter"); + await fizzRow.click(); + await expect(input).toHaveText("Ask @Fizz "); await page.keyboard.type(" for a hand"); const baselineCommands = await readCommandLog(page); @@ -1474,7 +1602,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, { @@ -1495,12 +1623,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(dropdown).not.toBeVisible(); + await expect( + 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 }); @@ -1511,7 +1651,20 @@ test("stale channel-member agents absent from managed and relay directories stay const input = page.getByTestId("message-input"); await input.fill("@mira"); - await expect(autocomplete(page)).toHaveCount(0); + await expect( + 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 ({ @@ -1546,7 +1699,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("/"); @@ -1555,13 +1708,19 @@ test("relay-only shared agents stay hidden from DM mentions", async ({ await page.getByTestId("message-input").fill("@alice"); - await expect(autocomplete(page)).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 suggestions are removed when channel authorization disappears", async ({ +test("cached relay-agent members become unavailable when channel authorization disappears", async ({ page, }) => { - await installMockBridge(page, { userSearchDelayMs: 10_000 }); + await installMockBridge(page, { userSearchDelayMs: 100 }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1594,7 +1753,24 @@ test("cached relay-agent suggestions are removed when channel authorization disa await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); }, GENERAL_CHANNEL_ID); - await expect(aliceSuggestion).toHaveCount(0); + 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 }) => { @@ -1701,7 +1877,9 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`; await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toContainText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull(); @@ -1747,6 +1925,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, @@ -1761,66 +1942,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: ["mock directory unavailable", null], - 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(autocomplete(page)).toHaveCount(0); - - await page.evaluate(async () => { - await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ - queryKey: ["relay-agents"], + { 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 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, @@ -2000,6 +2237,491 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("cached-visible revoked relay agent selection is denied without a directory refetch", async ({ + page, +}) => { + await installMockBridge(page, { + 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(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + + // Backend-only policy revocation: the targeted revalidation command omits + // quinn while nothing refreshes or invalidates the installed chooser cache. + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + // The cached directory keeps serving the same eligible row. + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + await quinnRow.click(); + await expect(page.getByTestId("composer-address-lock-status")).toHaveText( + /Access changed\. Selection was not inserted\./, + ); + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + + const deniedCommands = await captureMentionCommandBoundary(page, "final"); + // The denial came from the targeted prepare-phase revalidation seam… + expect(commandCount(deniedCommands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 1, + ); + // …not from a directory refresh: the cached row was never refetched. + expect(commandCount(deniedCommands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); + // Nothing was inserted, invited, started, or published. + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(deniedCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + + // Control: the identical cached row and click admit once the backend policy + // re-allows the agent, still with no directory refetch. + await page.evaluate(() => { + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = []; + }); + await quinnRow.click(); + await expect(input.locator(".mention-chip")).toHaveCount(1); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + const controlCommands = await readCommandLog(page); + expect(commandCount(controlCommands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(controlCommands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); +}); + +// Deferred IPC seam, the remote-owned-mentions.spec.ts holdInviteCommand +// pattern: hold the exact next targeted revalidation so a real click +// admission parks at the authority boundary instead of racing the browser. +type MentionGateWindow = Window & { + __TAURI_INTERNALS__: { + invoke: (command: string, payload?: unknown) => Promise; + }; + mentionGateEntered?: boolean; + releaseMentionGate?: () => void; +}; +async function holdMentionGateCommand( + page: import("@playwright/test").Page, + command: string, +) { + await page.evaluate( + ({ heldCommand }) => { + const state = window as unknown as MentionGateWindow; + const invoke = state.__TAURI_INTERNALS__.invoke; + const gate = new Promise((resolve) => { + state.releaseMentionGate = resolve; + }); + state.__TAURI_INTERNALS__.invoke = async (command, payload) => { + if (command !== heldCommand) return invoke(command, payload); + state.__TAURI_INTERNALS__.invoke = invoke; + state.mentionGateEntered = true; + await gate; + return invoke(command, payload); + }; + }, + { heldCommand: command }, + ); +} +async function waitForMentionGate(page: import("@playwright/test").Page) { + await expect + .poll(() => + page.evaluate( + () => (window as unknown as MentionGateWindow).mentionGateEntered, + ), + ) + .toBe(true); +} +async function releaseMentionGate(page: import("@playwright/test").Page) { + await page.evaluate(() => { + (window as unknown as MentionGateWindow).releaseMentionGate?.(); + }); +} + +test("navigating to mention Options during a held selection inserts nothing late", async ({ + page, +}) => { + await installMockBridge(page, { + 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(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + // Hold the fresh targeted revalidation, then start the selection normally. + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await quinnRow.click(); + await waitForMentionGate(page); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Checking access", + ); + + // Native Shift+Tab from the editor the click never defocused: the app's + // real handler must hand focus to the overlay's Options trigger. + await expect(input).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByTestId("message-composer").getByTestId("mention-options-trigger"), + ).toBeFocused(); + + // Release the held authority response and settle the downstream DOM. + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.waitForTimeout(300); + + // A selection navigated away from the editor must not insert late: the + // draft keeps its raw text, no chip or addressed-agent side effect appears, + // and nothing is invited, started, or published. + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect( + page.getByTestId("composer-address-lock-status"), + ).not.toContainText("Automatically mentioning"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); +}); + +test("navigating away from the mention row pin during its held revalidation pins nothing late", async ({ + page, +}) => { + await installMockBridge(page, { + 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(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const composer = page.getByTestId("message-composer"); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + // Keyboard route into the overlay, no pointer and no test-side focus: the + // app's editor handler hands focus to the Options trigger, then a native + // Tab reaches the row's pin control (row buttons are pointer-guarded + // non-tab stops). + const optionsTrigger = composer.getByTestId("mention-options-trigger"); + const quinnPinToggle = composer.getByTestId( + `mention-always-address-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ); + await expect(input).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(optionsTrigger).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(quinnPinToggle).toBeFocused(); + // The focused control is the pin by role, name and pressed state, and the + // editor is not focused: an Enter here is a pin activation, not a + // selection duplicate. + await expect( + composer.getByRole("button", { + name: "Automatically mention quinn", + exact: true, + }), + ).toBeFocused(); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "false"); + await expect(input).not.toBeFocused(); + + // Start the PIN with a native Enter on the focused pin control and hold + // the pin's fresh targeted revalidation at the deferred-IPC seam. + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await page.keyboard.press("Enter"); + await waitForMentionGate(page); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Checking access", + ); + + // Native Shift+Tab from the pin — the editor's key handler sees no overlay + // events — must really move focus off the pin control. + await page.keyboard.press("Shift+Tab"); + await expect(optionsTrigger).toBeFocused(); + await expect(quinnPinToggle).not.toBeFocused(); + + // Release the held authority response and settle the downstream DOM. + const draftAtDeparture = await input.evaluate( + (element) => element.textContent, + ); + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.waitForTimeout(300); + + // A pin navigated away from must not apply late: the draft keeps its raw + // query text, no implicit prefix or highlight appears, no chip, no + // address-lock audience, no announcement, the pin stays unpressed, and + // nothing is invited, started, or published. + await expect + .poll(() => input.evaluate((element) => element.textContent)) + .toBe(draftAtDeparture); + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toHaveCount(0); + await expect( + page.getByTestId("composer-address-lock-status"), + ).not.toContainText("Automatically mentioning"); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "false"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + + // Positive control: the same native pin activation without navigating + // away must pin through the same real gate, proving the exercised control + // is a working pin and the quiet settlement above was not an inert route. + await page.keyboard.press("Tab"); + await expect(quinnPinToggle).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 2); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "true"); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toBeVisible(); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Automatically mentioning quinn", + ); + await expect(input).toHaveText("@quinn "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("quinn"); +}); + +for (const change of ["draft", "selection range"] as const) { + test(`editing and restoring the ${change} during a held mention selection inserts nothing late`, async ({ + page, + }) => { + await installMockBridge(page, { + 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(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const composer = page.getByTestId("message-composer"); + const input = page.getByTestId("message-input"); + // Read-only DOM offsets: no editor state or browser selection is changed. + const readCaret = () => + input.evaluate((element) => { + const selection = window.getSelection(); + if ( + !selection?.anchorNode || + !selection.focusNode || + !element.contains(selection.anchorNode) || + !element.contains(selection.focusNode) + ) { + return null; + } + const offset = (node: Node, position: number) => { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, position); + return range.toString().length; + }; + return { + anchor: offset(selection.anchorNode, selection.anchorOffset), + focus: offset(selection.focusNode, selection.focusOffset), + collapsed: selection.isCollapsed, + }; + }); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + await expect(input).toBeFocused(); + const originalCaret = await readCaret(); + expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true }); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await quinnRow.click(); + await waitForMentionGate(page); + const status = page.getByTestId("composer-address-lock-status"); + await expect(status).toContainText("Checking access"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); + + if (change === "draft") { + // Behavior coverage: text edits also invalidate the query revision. + await page.keyboard.press("Backspace"); + await expect(input).toHaveText("@quin"); + await expect(input).toBeFocused(); + await page.keyboard.type("n"); + } else { + // Query snapshots use the selection ANCHOR, not its moving head. + // Extend and retract a real native range without changing text/anchor: + // query revision cannot mask removal of editor cancellation listeners. + await page.keyboard.press("Shift+ArrowLeft"); + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual({ + anchor: 6, + focus: 5, + collapsed: false, + }); + await page.keyboard.press("Shift+ArrowRight"); + } + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); + + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + // Same downstream settlement window as the adjacent held-gate probes. + await page.waitForTimeout(300); + + await expect(input).toBeFocused(); + await expect(input).toHaveText("@quinn"); + await expect.poll(readCaret).toEqual(originalCaret); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toHaveCount(0); + await expect(status).not.toContainText("Checking access"); + await expect(status).not.toContainText("Automatically mentioning"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + }); +} + test("selected relay agents are invited as bots before sending", async ({ page, }) => { @@ -2021,7 +2743,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"); @@ -2032,11 +2757,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, ); @@ -2089,7 +2820,9 @@ test("selected relay agents revoked after the invite prompt cause no side effect await inviteButton.click(); await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toHaveText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); @@ -2143,7 +2876,9 @@ test("selected relay agents revoked during send emit no p tag", async ({ }); await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toHaveText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); @@ -2256,7 +2991,12 @@ test("relay-only allowlisted agents stay hidden outside their channel", async ({ await page.getByTestId("message-input").fill("@quinn"); - await expect(autocomplete(page)).toHaveCount(0); + await expect( + page.getByRole("status").filter({ hasText: "No mentions found" }), + ).toBeVisible(); + await expect( + autocomplete(page).locator("[data-testid^=mention-suggestion-]"), + ).toHaveCount(0); }); test("owner-only builds admit cross-owner relay agents authorized for anyone", async ({ @@ -2311,14 +3051,19 @@ test("relay-only excluded agents stay hidden from channel mentions", async ({ await page.getByTestId("message-input").fill("@quinn"); - await expect(autocomplete(page)).toHaveCount(0); + await expect( + page.getByRole("status").filter({ hasText: "No mentions found" }), + ).toBeVisible(); + await expect( + autocomplete(page).locator("[data-testid^=mention-suggestion-]"), + ).toHaveCount(0); }); test("shared agents wait for initial directory authorization", async ({ page, }) => { await installMockBridge(page, { - agentListDelayMs: 1_000, + deferAgentList: true, relayAgents: [ { pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, @@ -2335,7 +3080,15 @@ test("shared agents wait for initial directory authorization", async ({ await page.getByTestId("message-input").fill("@quinn"); - await expect(autocomplete(page)).toHaveCount(0); + await expect( + page.getByRole("status").filter({ hasText: "Loading mentions" }), + ).toBeVisible(); + await expect(autocomplete(page).getByText("quinn")).toHaveCount(0); + await page.evaluate(() => { + const release = window.__BUZZ_E2E_RELEASE_AGENT_LIST__; + if (!release) throw new Error("Directory release seam unavailable"); + release(); + }); await expect(autocomplete(page).getByText("quinn")).toBeVisible({ timeout: 3_000, }); @@ -2469,7 +3222,8 @@ test("mentioning a non-member managed agent adds and starts it before sending", const fizzRow = dropdown.locator("button", { hasText: "fizz" }); await expect(fizzRow).toBeVisible(); await expect(fizzRow.getByText("not in channel")).toBeVisible(); - await input.press("Enter"); + await fizzRow.click(); + await expect(input).toHaveText("Loop in @fizz "); const baselineCommands = await readCommandLog(page); const baselineAddCount = commandCount( @@ -2575,7 +3329,8 @@ test("mentioning a non-member provider managed agent deploys it before sending", const portalRow = dropdown.locator("button", { hasText: "portal" }); await expect(portalRow).toBeVisible(); await expect(portalRow.getByText("not in channel")).toBeVisible(); - await input.press("Enter"); + await portalRow.click(); + await expect(input).toHaveText("Loop in @portal "); const baselineCommands = await readCommandLog(page); const baselineAddCount = commandCount( @@ -3015,7 +3770,7 @@ test("global non-member people can be selected from channel mentions", async ({ await expect(dropdown.getByText("not in channel")).toBeVisible(); }); -test("duplicate global people with the same visible identity collapse in channel mentions", async ({ +test("distinct same-name global people remain independently selectable", async ({ page, }) => { await installMockBridge(page, { @@ -3039,7 +3794,34 @@ test("duplicate global people with the same visible identity collapse in channel await input.fill("@pip"); const dropdown = autocomplete(page); - await expect(dropdown.locator("button", { hasText: "Pip" })).toHaveCount(1); + const keys = [CASEY_PROFILE_PUBKEY, "2".repeat(64)]; + await waitForCompleteMentionSearch(page, "pip"); + const displayedKey = await dropdown + .locator("[data-testid^=mention-suggestion-]") + .first() + .getAttribute("data-testid"); + await input.press("Tab"); + await expect(input).toHaveText("@Pip "); + expect(keys.some((key) => displayedKey === `mention-suggestion-${key}`)).toBe( + true, + ); + for (const key of keys) { + await input.fill("@pip"); + const row = dropdown.getByTestId(`mention-suggestion-${key}`); + await expect(row).toHaveCount(1); + await expect(row).toContainText("Pip"); + await expect(row.locator("[title^=npub]")).toHaveCount(1); + await row.locator("button").first().click(); + await page.keyboard.type(key.slice(0, 1)); + const content = `@Pip ${key.slice(0, 1)}`; + await page.getByTestId("send-message").click(); + await expect(page.getByRole("alertdialog")).toBeVisible(); + expect(await readOutgoingMentionPubkeys(page, content)).toBeNull(); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toEqual([key]); + } }); test("sent non-member person mention uses the normal mention style", async ({ @@ -3052,10 +3834,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 input.press("Enter"); + 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 @@ -3064,6 +3851,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 ({ @@ -3087,7 +3892,8 @@ test("sent managed non-member agent mention uses the agent mention style", async const dropdown = autocomplete(page); await expect(dropdown.getByText("charlie")).toBeVisible(); - await input.press("Enter"); + await dropdown.getByText("charlie", { exact: true }).click(); + await expect(input).toHaveText("Loop in @charlie "); await page.keyboard.type(" too"); await page.getByTestId("send-message").click(); @@ -3146,23 +3952,38 @@ test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)", await expect(input.locator("br")).toHaveCount(1); }); -test("keyboard navigation selects mention with Enter", async ({ page }) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - const input = page.getByTestId("message-input"); - await input.fill("@bo"); - - const dropdown = autocomplete(page); - await expect(dropdown.getByText("bob")).toBeVisible(); - - // Press Enter to select the first (and only) suggestion - await input.press("Enter"); - - // Should insert @bob and NOT send the message - await expect(input).toHaveText("@bob "); -}); +for (const channel of ["general", "watercooler"]) { + test(`keyboard navigation selects mention with Enter in ${channel}`, async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId(`channel-${channel}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channel); + if (channel === "watercooler") + await page.getByRole("button", { name: "Start a new post..." }).click(); + + const input = page.getByTestId("message-input"); + await input.click(); + await page.keyboard.type("@bo"); + + const dropdown = page.getByTestId("mention-autocomplete"); + await expect(dropdown.getByText("bob")).toBeVisible(); + + // Select deliberately after the current search settles; a visible row alone + // does not authorize implicit completion while more results may arrive. + await waitForCompleteMentionSearch(page, "bo"); + const baselineCommands = await readCommandLog(page); + await input.press("ArrowDown"); + await input.press("Enter"); + + // Should insert @bob and NOT send the message + await expect(input).toHaveText("@bob "); + await expect(input.locator("p")).toHaveCount(1); + expect(commandCount(await readCommandLog(page), "sign_event")).toBe( + commandCount(baselineCommands, "sign_event"), + ); + }); +} test("Escape dismisses autocomplete dropdown", async ({ page }) => { await page.goto("/"); @@ -3694,3 +4515,84 @@ test("delayed inaccessible agent profile keeps all actions hidden", async ({ ), ).toHaveCount(0); }); + +for (const channel of ["general", "watercooler"]) { + test(`leaving completion dismisses the ${channel} picker without editing the draft`, async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId(`channel-${channel}`).click(); + if (channel === "watercooler") + await page.getByRole("button", { name: "Start a new post..." }).click(); + const input = page.getByTestId("message-input"); + await input.click(); + // ProseMirror restores a DOM-only jump to document start within 200ms + // of focus (domobserver.ts). Type at a human pace rather than filling + // and moving in that browser-focus recovery window. + await input.pressSequentially("hello @bo", { delay: 30 }); + await expect(page.getByTestId("mention-autocomplete-layer")).toBeVisible(); + await expect( + page.locator("[data-mention-suggestion-index]").first(), + ).toBeVisible(); + await waitForAnimations(page); + await expect(input).toBeFocused(); + // Native line-start movement differs by browser host OS; Meta+ArrowLeft + // leaves the caret unchanged in Linux Chromium (including CI). + await input.press( + process.platform === "darwin" ? "Meta+ArrowLeft" : "Home", + ); + await expect + .poll(() => input.evaluate(() => window.getSelection()?.anchorOffset)) + .toBe(0); + await expect(page.getByTestId("mention-autocomplete-layer")).toBeHidden(); + await input.press("Tab"); + 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/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 9cc17c11acb..c8fb9b6ed54 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -9,6 +9,10 @@ import { } from "../helpers/bridge"; import { expectEmojiMartStylesInstalled } from "../helpers/css"; import { installFakeCamera } from "../helpers/fakeCamera"; +import { + addWelcomeCollision, + WELCOME_COLLISION, +} from "../helpers/welcomeCollision"; import { E2E_IDENTITY_OVERRIDE_STORAGE_KEY, seedActiveIdentity, @@ -490,7 +494,7 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention( // Make selection intent explicit; do not remove the colliding fixture or // relax extraction. The resulting event must tag only our starter identity. await input.fill(""); - await input.fill(content); + await input.pressSequentially(content); await page.getByTestId(`mention-suggestion-${fizz[0].pubkey}`).click(); await page.getByTestId("send-message").click(); await expect.poll(sentRecipients).toEqual([[fizz[0].pubkey]]); @@ -3349,7 +3353,11 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); - await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await installMockBridge( + page, + { managedAgents: [WELCOME_COLLISION] }, + { skipOnboardingSeed: true }, + ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); @@ -3359,6 +3367,10 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone await expect(page.getByTestId("channel-general")).toBeVisible(); await expectStarterChannels(page); await expectWelcomeGuideIntro(page); + expect(await commandCount(page, "create_managed_agent")).toBe(3); + const channelId = await getWelcomeChannelId(page); + if (!channelId) throw new Error("Missing Welcome channel"); + await addWelcomeCollision(page, channelId); await expectWelcomeComposerBannerCompletesAfterPersonaMention(page); }); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 39e8c1004d2..c36d9e9499d 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -72,6 +72,15 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } +async function waitForMentionChoice(composer: Locator, pubkey: string) { + // The menu shell is also visible while loading; Tab needs an installed row. + await expect( + composer + .getByTestId(`mention-suggestion-${pubkey}`) + .getByRole("button", { name: /^Mention / }), + ).toBeEnabled(); +} + async function readComposerCaret(input: Locator) { return input.evaluate((element) => { const selection = window.getSelection(); @@ -463,7 +472,7 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => { const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); @@ -500,8 +509,11 @@ test("disabling automatic mentions leaves the composer empty after send", async await expect(preference).toHaveAttribute("data-state", "unchecked"); await input.press("Escape"); + await page.evaluate(() => { + window.__BUZZ_E2E__.mock!.userSearchDelayMs = 500; + }); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); await input.type("test"); await expect(input).toHaveText("@Morgarita test"); @@ -571,13 +583,34 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async }) => { await installAudienceFixtures(page); await openGeneral(page); + await expect + .poll(() => page.evaluate(() => !!window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__)) + .toBe(true); await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); + await expect( + page.getByTestId("message-row").filter({ hasText: "Please ask Vogue" }), + ).toBeVisible(); const input = channelComposer(page).getByTestId("message-input"); - await input.fill("draft text"); + await input.click(); + await input.pressSequentially("draft text", { delay: 30 }); await input.press("ArrowLeft"); await input.press("ArrowLeft"); await expect.poll(() => readComposerCaret(input)).toBe(8); + // Native ArrowLeft updates the DOM before ProseMirror's selection observer. + // Wait for both sides of the actual editor boundary before the shortcut. + await expect + .poll(() => + input.evaluate((element) => { + const editor = ( + element as HTMLElement & { + editor: { state: { selection: { anchor: number } } }; + } + ).editor; + return editor.state.selection.anchor - 1; + }), + ) + .toBe(8); await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); @@ -924,7 +957,7 @@ test("the mention Options controls are reachable and operable by keyboard", asyn // Forward Tab is unchanged by the Shift+Tab route. await mainInput.fill("@Morg"); - await expect(list).toBeVisible(); + await waitForMentionChoice(mainComposer, AGENT_A); await mainInput.press("Tab"); await expect(mainInput).toHaveText("@Morgarita "); await expect(list).toHaveCount(0); @@ -982,7 +1015,7 @@ test("a manual mention persists when automatic mentions are enabled", async ({ const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); await expect( @@ -1064,7 +1097,7 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); @@ -1104,7 +1137,7 @@ test("the auto-pin popover remains open while hovered", async ({ page }) => { const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); const autoPinConfirmation = page.getByTestId( @@ -1128,7 +1161,7 @@ test("removing the mention chip dismisses the auto-pin popover", async ({ const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); const autoPinConfirmation = page.getByTestId( @@ -1203,7 +1236,7 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a ).toHaveCount(0); await rootInput.fill("@cla"); - await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(rootComposer, AGENT_A); await rootInput.press("Tab"); await rootInput.type("one time"); await rootInput.press("Enter"); @@ -1216,9 +1249,7 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a const activeThreadComposer = threadComposer(page); const threadInput = activeThreadComposer.getByTestId("message-input"); await threadInput.fill("@cla"); - await expect( - activeThreadComposer.getByTestId("mention-autocomplete"), - ).toBeVisible(); + await waitForMentionChoice(activeThreadComposer, AGENT_A); await threadInput.press("Tab"); await expect( activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`), @@ -1233,9 +1264,7 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a const restoredRootInput = channelComposer(page).getByTestId("message-input"); await restoredRootInput.fill("@cla"); - await expect( - channelComposer(page).getByTestId("mention-autocomplete"), - ).toBeVisible(); + await waitForMentionChoice(channelComposer(page), AGENT_A); await restoredRootInput.press("Tab"); await expect( channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), @@ -1262,7 +1291,7 @@ test("an unchecked agent remains excluded while automatic mentions stay enabled" await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); await expect(input).toHaveText(""); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); await input.type("one time"); await input.press("Enter"); @@ -1296,7 +1325,7 @@ test("re-adding a deleted automatic mention restores its automatic mention state ).toHaveCount(0); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); await expect( @@ -1365,8 +1394,8 @@ test("an authored duplicate leading mention survives draft restoration", async ( await openGeneral(page); await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); - // Exact typed mentions now resolve on Space, so both the automatic prefix and - // the authored duplicate retain mention identity after restoration. + // Both occurrences use the already registered exact label. Decoration must + // not depend on raw Space completing a picker query or refreshing the names. await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { @@ -1606,7 +1635,7 @@ test("reduced motion removes addressed agents without spatial animation", async const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await waitForMentionChoice(composer, AGENT_A); await input.press("Tab"); const removeButton = composer.getByTestId( `composer-address-lock-remove-${AGENT_A}`, diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index 4df65c7cbf3..b9d5e9efb8d 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -40,7 +40,10 @@ async function install(page: Page) { async function select(page: Page) { await page.getByTestId("message-input").fill("@Remote"); const row = page.getByTestId(`mention-suggestion-${REMOTE}`); - await expect(row).toContainText("RemoteScout"); + // New DMs have no destination roster yet. Do not let PR6's five-second + // evidence expiry conceal a disabled-query readiness deadlock in PR5. + await expect(row).toContainText("RemoteScout", { timeout: 4000 }); + await expect(row.locator("button").first()).toBeEnabled(); await row.locator("button").first().click(); await page.keyboard.type("hello"); } @@ -245,7 +248,9 @@ test("membership revoked at final publish keeps draft and emits no message", asy }); await page.getByRole("button", { name: "Invite", exact: true }).click(); await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(page.getByTestId("message-input")).toHaveText( "@RemoteScout hello", @@ -459,8 +464,13 @@ for (const stage of ["add", "publish"] as const) { } } -for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { - test(`B1 authored deletion before thread switch preserves storage and ${incoming}`, async ({ +for (const { incoming, savedFirst } of [ + "unrelated thread B draft", + "@RemoteScout hello", +].flatMap((incoming) => + [false, true].map((savedFirst) => ({ incoming, savedFirst })), +)) { + test(`B1 authored deletion before thread switch preserves storage and ${incoming} (${savedFirst ? "persisted" : "unsaved"})`, async ({ page, }) => { await install(page); @@ -520,8 +530,6 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { await page.getByRole("button", { name: "Invite", exact: true }).click(); await waitForInviteGate(page); await expect(input).toHaveText(""); - await input.fill("new authored text"); - await input.fill(""); const sourceRecord = () => page.evaluate(([root, otherRoot]) => { const key = Object.keys(localStorage).find((key) => @@ -533,12 +541,67 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { throw new Error("control B draft missing"); return drafts[`thread:${root}`] ?? null; }, roots); - expect(await sourceRecord()).toBeNull(); - // Expando proves the actual editor DOM host survived A -> B. - await input.evaluate((el) => - el.setAttribute("data-lifecycle-host", "retained"), - ); + await input.fill("new authored text"); + if (savedFirst) { + // Additional positive control: real scope cleanup saves live nonempty + // text, exact refs and selection. Keep the rapid unsaved case above. + await navigate(roots[1]); + expect(await sourceRecord()).toMatchObject({ + content: "new authored text", + mentionRefs: [], + selectionStart: 17, + selectionEnd: 17, + }); + await navigate(roots[0]); + await expect(input).toHaveText("new authored text"); + } + // Native fill can return before DOMObserver dispatches the PM edit. + // Arm before deletion; observe the first update FROM the authored document, + // not eventual emptiness/storage quiescence. Production onUpdate was + // registered first and synchronously owns empty-authority persistence. + const deletion = await input.evaluateHandle((el, root) => { + el.setAttribute("data-lifecycle-host", "retained"); + const editor = ( + el as HTMLElement & { editor: import("@tiptap/core").Editor } + ).editor; + return { + completed: new Promise((resolve) => { + const onUpdate = ({ + transaction, + }: import("@tiptap/core").EditorEvents["update"]) => { + if (transaction.before.textContent !== "new authored text") return; + editor.off("update", onUpdate); + const key = Object.keys(localStorage).find((key) => + key.startsWith("buzz-drafts.v2"), + ); + resolve({ + dom: el.textContent, + doc: editor.getJSON(), + from: editor.state.selection.from, + to: editor.state.selection.to, + source: key + ? (JSON.parse(localStorage.getItem(key) ?? "{}")[ + `thread:${root}` + ] ?? null) + : "draft storage scope missing", + }); + }; + editor.on("update", onUpdate); + }), + }; + }, roots[0]); + await input.fill(""); + const deleted = await deletion.evaluate(({ completed }) => completed); + // No assertion/poll between action completion and outgoing-key cleanup. await navigate(roots[1]); + expect(deleted).toEqual({ + dom: "", + doc: { type: "doc", content: [{ type: "paragraph" }] }, + from: 1, + to: 1, + source: null, + }); + await deletion.dispose(); await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); await expect(input).toHaveText(incoming); await expect(page.getByRole("alertdialog")).toHaveCount(0); diff --git a/desktop/tests/e2e/team-mentions.spec.ts b/desktop/tests/e2e/team-mentions.spec.ts index f6c5cf23580..b879c2127ed 100644 --- a/desktop/tests/e2e/team-mentions.spec.ts +++ b/desktop/tests/e2e/team-mentions.spec.ts @@ -56,7 +56,7 @@ test("owned team mention unfurls into its agents", async ({ page }) => { clip: { x: 240, y: 380, width: 800, height: 320 }, }); - await input.press("Enter"); + await teamRow.click(); await expect .poll(() => input.evaluate((element) => element.textContent)) .toContain("Coordinate with Launch Team(@Planner @Builder @Reviewer)"); 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/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 725fe1cb281..e029d0317c1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -260,6 +260,8 @@ type MockBridgeOptions = { relayAgentListErrors?: (string | null)[]; /** Delay both managed and relay agent directory reads. */ agentListDelayMs?: number; + /** Hold directory reads until __BUZZ_E2E_RELEASE_AGENT_LIST__. */ + deferAgentList?: boolean; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; addChannelMembersDelayMs?: number; diff --git a/desktop/tests/helpers/welcomeCollision.ts b/desktop/tests/helpers/welcomeCollision.ts new file mode 100644 index 00000000000..c50fbc03e62 --- /dev/null +++ b/desktop/tests/helpers/welcomeCollision.ts @@ -0,0 +1,71 @@ +import { expect, type Page } from "@playwright/test"; +import { TEST_IDENTITIES } from "./bridge"; + +export const WELCOME_COLLISION = { + pubkey: "c".repeat(64), + name: "Fizz", + personaId: "builtin:fizz", + status: "stopped" as const, +}; + +/** Add a deliberate same-name member and deliver the relay event mock IPC omits. */ +export async function addWelcomeCollision(page: Page, channelId: string) { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "Welcome", + }) ?? false, + ), + ) + .toBe(true); + await page.evaluate( + async ({ channelId, pubkey, actor }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!invoke || !emit) + throw new Error("Mock membership seams unavailable"); + const result = (await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + })) as { added: string[]; errors: unknown[] }; + if (result.errors.length || !result.added.includes(pubkey)) { + throw new Error("Collision fixture membership was not added"); + } + // Real relay side_effects emits member_joined after kind:9000. Mock + // add_channel_members only mutates backend arrays. Model delivery, not + // the resulting QueryClient state: production owns roster invalidation. + emit({ + channelName: "Welcome", + kind: 40099, + content: JSON.stringify({ + type: "member_joined", + actor, + target: pubkey, + }), + }); + }, + { + channelId, + pubkey: WELCOME_COLLISION.pubkey, + actor: TEST_IDENTITIES.tyler.pubkey, + }, + ); + await expect + .poll(() => + page.evaluate( + ({ channelId, pubkey }) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + getQueryData: (key: string[]) => { pubkey: string }[] | undefined; + }; + return client + .getQueryData(["channels", channelId, "members"]) + ?.some((member) => member.pubkey === pubkey); + }, + { channelId, pubkey: WELCOME_COLLISION.pubkey }, + ), + ) + .toBe(true); +} diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 2dc231dade9..2fa0aafc550 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -63,3 +63,82 @@ refs, typed members and personas before presence selection; they are never match in a separate, narrower pass. Current selections (including unbound personas) take precedence over same-label fallback refs. Historical unresolved identities still preserve non-notifying metadata, without claiming a literal binding. + +## Stable completion choices + +Changing completion text requests a new list. The picker shows loading while +needed data arrives, then installs one set of up to 50 choices. Background +membership, directory, presence and ranking updates do not replace or reorder +those choices. A subsequent text change or explicit open uses current evidence. +Create/Add membership refreshes discovery for that next open, not a moving list. + +Arrow keys select an index in the displayed set. Tab, plain Enter and clicking +choose that displayed identity, including same-name rows; they do not require +global uniqueness or exhaust search pagination. Space is still implicit exact +name completion: partial, longer-name and ambiguous matches stay literal. + +Leaving the completion, dismissal and navigation abandon its request and +selection. Closed or superseded requests cannot install their results. Explicit +no-trigger menus open/reset normally; toggling an automatic address reopens a +fresh menu rather than preserving selection across an edited document. + +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. +Failed or pending Retry does not authorize cached choices. + +## Fresh selection and pin admission + +Chat pointer, Enter, Tab and exact-Space choices, explicit automatic-address +adds (including the closed-picker default-agent shortcut), and standalone forum +choices share one cancellable prepare/commit operation. Discovery and cached +availability are preflight only: the exact selected agent keys and destination +are freshly revalidated in prepare mode. A team is admitted as its captured +recipient set, never partially or by resolving its name again. Human-only and +unresolved-persona choices retain their existing recipient semantics. + +No label reservation, selected-agent intent, successful-selection history, +editor replacement, automatic audience or generated-prefix provenance is added +while checking. A single fenced synchronous commit owns those effects. The raw +insertion implementation is private; restoration/registration of existing draft +intent is not a new user selection. The displayed-row WeakMap associates live +availability overlays with installed row identities; it is not a retained-list +or moving-ranking authority. The closed-picker shortcut holds at most one exact +issued choice, bound to the editor/query revision. + +“Checking access…” is visible and announced in chat and forum. Access denial +leaves the draft unchanged and says access changed; failed lookup or the +15-second timeout says access could not be checked. Selecting again retries +with fresh evidence. A duplicate action for the same pending choice is consumed, +not queued for publication. A different choice supersedes it. Typing, native +input, selection transactions, dismissal, navigation, submission and unmount +abandon pending work; returning to the same text, caret or scope does not revive +it. Native editing is never disabled by selection admission. Unexpected commit +errors are reported as a failure to finish selection (inspect the draft before +retry), not mislabeled as an authority denial. + +Unpin/removal remains available without permission and cancels pending adds. +Existing automatic-address restoration remains separate from new pin admission. +Native Enter outside the chooser and non-exact/ambiguous Space retain their +normal handling. Only successful insertion settles the mention caret. + +Selection is not a send permit: publication independently revalidates authority +for the actual destination, including revocation after successful selection. +Offline fresh admission therefore fails closed without clearing the draft. +Loss of live choice eligibility abandons a pending admission immediately; a +later Retry restoring that same row cannot revive the older operation. +Focused coverage in `freshMentionChatJourney.test.mjs` mounts the production +chat picker with Tiptap and the actual standalone `ForumComposer`; its DOM is +JSDOM with fixture IPC, **not** browser or live-relay evidence. Browser, +relay-backed revocation, full-package gates and independent review remain +separate release gates.