From 801cb94fcd5eec8b9261886a55d885c508986240 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 16:13:09 -0400 Subject: [PATCH 1/9] fix(desktop): keep mention choices safe when access changes Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/freshMentionChatJourney.test.mjs | 997 ++++++++++++++++++ .../channels/mentionAdmissionJourney.test.mjs | 35 +- .../src/features/forum/ui/ForumComposer.tsx | 32 +- .../agentMentionAdmissionEvidence.test.mjs | 148 +++ .../messages/lib/agentMentionRevalidation.ts | 21 +- .../messages/lib/useMentionAdmission.ts | 103 ++ .../messages/lib/useMentionAdmissionEditor.ts | 34 + .../features/messages/lib/useMentionQuery.ts | 4 + .../src/features/messages/lib/useMentions.ts | 109 +- .../features/messages/ui/MessageComposer.tsx | 8 +- .../ui/useAgentAddressLockPicker.test.mjs | 77 ++ .../messages/ui/useAgentAddressLockPicker.ts | 222 ++-- desktop/tests/e2e/mentions.spec.ts | 463 +++++++- .../tests/e2e/remote-owned-mentions.spec.ts | 4 +- docs/mention-editor.md | 44 + 15 files changed, 2151 insertions(+), 150 deletions(-) create mode 100644 desktop/src/features/channels/freshMentionChatJourney.test.mjs create mode 100644 desktop/src/features/messages/lib/agentMentionAdmissionEvidence.test.mjs create mode 100644 desktop/src/features/messages/lib/useMentionAdmission.ts create mode 100644 desktop/src/features/messages/lib/useMentionAdmissionEditor.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..9a1335e109d --- /dev/null +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -0,0 +1,997 @@ +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(); + assert.equal(picker.announcement, ""); + }); +} +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(); + assert.equal(picker.announcement, ""); + } 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(); + assert.equal(picker.announcement, ""); + }); +} + +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("pin authority timeout is retryable and late allow cannot mutate", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("pin")); + 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 () => choose("pin")); + 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); +}); diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs index b9a6764643a..e9ac9d11383 100644 --- a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -265,7 +265,7 @@ for (const change of [ test(`a retained callback cannot bind after ${change}`, async () => { await setup({ owner: OTHER, visible: true, directoryVisible: true }); const staleRow = rows()[0]; - const staleInsert = mention.insertMention; + const staleInsert = captureInsert(mention.selectMention); assert.equal(staleRow.isAgent, true); assert.equal(mention.canSelectMention(staleRow), true); if (change === "policy-denied") state.policy = "owner-only"; @@ -311,7 +311,7 @@ test("only an exact current target can be selected", async () => { assert.equal(mention.canSelectMention(target), false); let edit; await act(async () => { - edit = mention.insertMention(target, 1); + edit = captureInsert(mention.selectMention)(target, 1); }); assert.equal(edit.insertText, ""); } @@ -340,7 +340,7 @@ for (const returnToOrigin of [false, true]) { await setup({ visible: true, directoryVisible: true }); const row = rows()[0], oldPin = picker.toggleAlwaysAddressAgent; - const oldInsert = mention.insertMention; + const oldInsert = captureInsert(mention.selectMention); const oldSelect = picker.selectMentionSuggestion; state.channelId = "22222222-2222-4222-8222-222222222222"; await render(); @@ -418,7 +418,7 @@ test("retained team cannot bind a removed exact member", async () => { 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 = mention.insertMention; + const old = captureInsert(mention.selectMention); state.missingDirectory = true; await act(async () => client.setQueryData(["managed-agents"], [])); await act(async () => @@ -477,7 +477,7 @@ test("duplicate team members cannot mask a recipient set change", async () => { new Set(row.teamMembers.map((m) => m.pubkey ?? m.personaId)).size, 1, ); - const insert = mention.insertMention; + const insert = captureInsert(mention.selectMention); await act(async () => client.setQueryData(["teams"], [{ ...team, personaIds: ["one", "two"] }]), ); @@ -529,7 +529,7 @@ for (const channelType of ["stream", "dm"]) { assert.equal(choice.pubkey, OTHER); let edit; await act(async () => { - edit = mention.insertMention(choice, 6); + edit = captureInsert(mention.selectMention)(choice, 6); }); assert.equal(mention.getDraftMentionRefs(edit.insertText)[0].pubkey, OTHER); }); @@ -593,7 +593,7 @@ test("background membership/search updates leave visible same-name rows and Tab }); assert.deepEqual(outcome.suggestion, selected); await act(async () => { - edit = mention.insertMention(outcome.suggestion, 6); + edit = captureInsert(mention.selectMention)(outcome.suggestion, 6); }); assert.equal( mention.getDraftMentionRefs(edit.insertText)[0].pubkey, @@ -657,7 +657,9 @@ test("leaving a completion and navigation discard choices; explicit reopen start const old = rows()[0]; await act(async () => mention.updateMentionQuery("plain", 5)); assert.equal(mention.isMentionOpen, false); - assert.equal(mention.insertMention(old, 5).insertText, ""); + 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); @@ -979,3 +981,20 @@ for (const failure of ["denied", "lookup-failed"]) { ); }); } +// 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/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index f2354579e0f..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"; @@ -143,6 +144,8 @@ export function ForumComposer({ }, }); + useMentionAdmissionEditor(richText.editor, mentions.cancelMentionAdmission); + const linkEditor = useLinkEditor(richText); onEditLinkRef.current = linkEditor.openFromClick; onLinkSelectionChangeRef.current = linkEditor.showFromCursor; @@ -154,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, ], @@ -229,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; @@ -299,6 +308,7 @@ export function ForumComposer({ media.pendingImetaRef, media.setPendingImeta, mentions.cancelMentionAutocomplete, + mentions.cancelMentionAdmission, mentions.extractMentionPubkeys, mentions.revalidateMentionPubkeys, mentions.clearMentions, @@ -480,6 +490,9 @@ export function ForumComposer({ const autocompletePosition = autocompleteBelow ? "below" : "above"; return ( <> + + {mentions.mentionAdmissionStatus} +
{ + mentions.cancelMentionAdmission(); + onCancel(); + }} size="sm" type="button" variant="ghost" diff --git a/desktop/src/features/messages/lib/agentMentionAdmissionEvidence.test.mjs b/desktop/src/features/messages/lib/agentMentionAdmissionEvidence.test.mjs new file mode 100644 index 00000000000..fba3f2ce251 --- /dev/null +++ b/desktop/src/features/messages/lib/agentMentionAdmissionEvidence.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + AgentMentionAuthorizationError, + revalidateAgentMentionPubkeys, +} from "./agentMentionRevalidation.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); +const HUMAN = "c".repeat(64); + +function fixture() { + let policy = "anyone"; + let calls = 0; + const options = { + pubkeys: [HUMAN, AGENT], + agentPubkeys: new Set([AGENT]), + currentPubkey: OWNER, + eligibilityScope: { type: "channel", channelId: "destination" }, + sharedChannelIds: new Set(["destination"]), + phase: "prepare", + refetchManagedAgents: async () => ({ data: [], error: null }), + fetchRelayAgents: async (keys) => { + calls++; + assert.deepEqual(keys, [AGENT]); + return [ + { + pubkey: AGENT, + ownerPubkey: OWNER, + respondTo: policy, + respondToAllowlist: [], + channelIds: ["destination"], + }, + ]; + }, + }; + return { + options, + revoke: () => { + policy = "nobody"; + }, + allow: () => { + policy = "anyone"; + }, + calls: () => calls, + }; +} + +function failure(reason) { + return (error) => { + assert.ok(error instanceof AgentMentionAuthorizationError); + assert.equal(error.reason, reason); + assert.match(error.message, /[Rr]etry/); + return true; + }; +} + +test("prepare reads fresh policy each time; retry and publication do not reuse success", async () => { + const f = fixture(); + const originalKeys = [...f.options.pubkeys]; + assert.deepEqual( + await revalidateAgentMentionPubkeys(f.options), + originalKeys, + ); + f.revoke(); // No cache invalidation/refetch outside the action under test. + await assert.rejects( + revalidateAgentMentionPubkeys(f.options), + failure("denied"), + ); + f.allow(); + assert.deepEqual( + await revalidateAgentMentionPubkeys(f.options), + originalKeys, + ); + f.revoke(); + await assert.rejects( + revalidateAgentMentionPubkeys({ ...f.options, phase: "publish" }), + failure("denied"), + ); + assert.equal(f.calls(), 4); + assert.deepEqual(f.options.pubkeys, originalKeys); +}); + +test("missing exact identity with complete evidence is denied", async () => { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...fixture().options, + fetchRelayAgents: async () => [], + }), + failure("denied"), + ); +}); + +for (const source of [ + "relay", + "managed-rejection", + "managed-error", + "managed-missing", +]) { + test(`${source}: incomplete evidence is lookup-failed, never a policy claim`, async () => { + const { options } = fixture(); + options.fetchRelayAgents = async () => []; + if (source === "relay") { + options.fetchRelayAgents = async () => { + throw new Error("offline"); + }; + } else if (source === "managed-rejection") { + options.refetchManagedAgents = async () => { + throw new Error("offline"); + }; + } else { + options.refetchManagedAgents = async () => ({ + data: source === "managed-error" ? [{ pubkey: AGENT }] : undefined, + error: source === "managed-error" ? new Error("offline") : null, + }); + } + await assert.rejects( + revalidateAgentMentionPubkeys(options), + failure("lookup-failed"), + ); + }); +} + +test("valid independent evidence wins despite unrelated lookup failure", async () => { + const { options } = fixture(); + assert.deepEqual( + await revalidateAgentMentionPubkeys({ + ...options, + refetchManagedAgents: async () => { + throw new Error("local offline"); + }, + }), + options.pubkeys, + ); + assert.deepEqual( + await revalidateAgentMentionPubkeys({ + ...options, + refetchManagedAgents: async () => ({ + data: [{ pubkey: AGENT }], + error: null, + }), + fetchRelayAgents: async () => { + throw new Error("relay offline"); + }, + }), + options.pubkeys, + ); +}); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index 1d7c197d6f8..a8a500121f6 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -13,12 +13,18 @@ export type MentionRevalidationOptions = { intendedAgentPubkeys?: readonly string[]; }; +/** Distinguishes missing authority from an incomplete fresh lookup, not policy. */ export class AgentMentionAuthorizationError extends Error { - constructor() { + readonly reason: "denied" | "lookup-failed"; + + constructor(reason: "denied" | "lookup-failed" = "denied") { super( - "Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.", + reason === "lookup-failed" + ? "Could not check access for a mentioned agent. Retry or remove the mention." + : "Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.", ); this.name = "AgentMentionAuthorizationError"; + this.reason = reason; } } @@ -90,7 +96,16 @@ export async function revalidateAgentMentionPubkeys({ if ( [...requestedAgentPubkeys].some((pubkey) => !admittedPubkeys.has(pubkey)) ) { - throw new AgentMentionAuthorizationError(); + // Either directory may independently prove an identity. If evidence is + // incomplete, failure to admit is not proof of a policy denial. Classify + // only after admission so unrelated outages cannot veto valid evidence. + const lookupFailed = + !relayDirectoryReady || + managedResult?.error !== null || + managedResult.data === undefined; + throw new AgentMentionAuthorizationError( + lookupFailed ? "lookup-failed" : "denied", + ); } return [...pubkeys]; } diff --git a/desktop/src/features/messages/lib/useMentionAdmission.ts b/desktop/src/features/messages/lib/useMentionAdmission.ts new file mode 100644 index 00000000000..be940ec8d28 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmission.ts @@ -0,0 +1,103 @@ +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 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; + clearTimeout(timer.current); + setStatus(""); + }, []); + // 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; + // 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; + if (!committing && !operation.valid()) setStatus(""); + } + } + })(); + }, + [cancel], + ); + return { begin, cancel, status }; +} diff --git a/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts new file mode 100644 index 00000000000..1967beee28e --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts @@ -0,0 +1,34 @@ +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(); + }; + const dom = editor.view.dom; + const keydown = (event: KeyboardEvent) => { + if (!["Enter", "Tab", " "].includes(event.key)) cancel(); + }; + editor.on("transaction", transaction); + dom.addEventListener("keydown", keydown); + dom.addEventListener("beforeinput", cancel); + dom.addEventListener("pointerdown", cancel); + return () => { + cancel(); + editor.off("transaction", transaction); + dom.removeEventListener("keydown", keydown); + dom.removeEventListener("beforeinput", cancel); + dom.removeEventListener("pointerdown", cancel); + }; + }, [editor, cancel]); +} diff --git a/desktop/src/features/messages/lib/useMentionQuery.ts b/desktop/src/features/messages/lib/useMentionQuery.ts index f9f126fb4d6..1c625ef4df8 100644 --- a/desktop/src/features/messages/lib/useMentionQuery.ts +++ b/desktop/src/features/messages/lib/useMentionQuery.ts @@ -17,11 +17,13 @@ export function useMentionQuery( ) { 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); }, []); @@ -46,6 +48,7 @@ export function useMentionQuery( 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. @@ -99,6 +102,7 @@ export function useMentionQuery( ); }, [prefixFor, read, request, scope]); return { + getRevision: () => revision.current, request: request?.scope === scope ? request : null, cancel, refresh: React.useCallback(() => { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0180b9ab468..49f4e199b7d 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,3 +1,4 @@ +import { useMentionAdmission } from "./useMentionAdmission"; import { isMentionActionable, markMentionCollisions, @@ -92,6 +93,7 @@ export function useMentions( [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 } = @@ -461,7 +463,17 @@ export function useMentions( profiles, recentMentionPubkeys: options?.recentMentionPubkeys, }); - const getDefaultAgentSuggestion = defaultAgentSuggestion; + // 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 @@ -498,6 +510,7 @@ export function useMentions( // 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) => { @@ -510,7 +523,7 @@ export function useMentions( ? candidate.teamId === row.teamId : candidate.personaId === row.personaId, ); - return { + const overlay: MentionSuggestion = { ...row, action: live ? live.action : "unavailable", presence: live?.presence ?? "unknown", @@ -520,6 +533,8 @@ export function useMentions( onRetry: live?.action === "unavailable" || !live ? retryMention : undefined, }; + rowOwners.current.set(overlay, row); + return overlay; }), [ snapshotSuggestions, @@ -582,12 +597,15 @@ export function useMentions( }, [admissionScope], ); + // Private synchronous commit; public user choices must pass admitMention. const insertMention = React.useCallback( (suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => { const prefix = currentMentionPrefix(); if ( !query.isCurrent() || - !suggestions.includes(suggestion) || + !snapshotSuggestions.some( + (row) => row === rowOwners.current.get(suggestion), + ) || !canSelectMention(suggestion) || selectionEnd !== query.read().cursor || (prefix && prefix.startIndex > selectionEnd) || @@ -673,7 +691,7 @@ export function useMentions( currentMentionPrefix, knownAgentPubkeys, query, - suggestions, + snapshotSuggestions, currentPubkey, channelId, ], @@ -706,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); @@ -795,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( @@ -840,12 +887,14 @@ export function useMentions( return { handled: false }; } 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 }; } @@ -892,6 +941,10 @@ export function useMentions( : { handled: true }; }; return { + admitMention, + selectMention, + cancelMentionAdmission: admission.cancel, + mentionAdmissionStatus: admission.status, canSelectMention, cancelMentionAutocomplete, clearMentions, @@ -903,8 +956,6 @@ export function useMentions( getMentionDisplayName, handleMentionKeyDown, hasResolvedMembers: members !== undefined, - insertMention, - insertResolvedMention, agentKnownNames: agentHighlightNames, isAgentPubkey, isManagedAgentPubkey, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0d4ea7f1dff..fa87f168548 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -563,6 +563,7 @@ function MessageComposerImpl({ onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { + mentions.cancelMentionAdmission(); const trimmed = syncComposerContentFromEditor().trim(); // Edit mode if (editTargetRef.current && onEditSaveRef.current) { @@ -682,6 +683,7 @@ function MessageComposerImpl({ media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, + mentions.cancelMentionAdmission, mentions.clearMentions, richText.clearContent, richText.setContent, @@ -906,7 +908,11 @@ function MessageComposerImpl({ {composerLinkPreviews} {addressLockAnnouncement} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 019291b6dfc..916fdbc7140 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -44,6 +44,10 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds isMentionOpen: true, openMentionPicker: (...args) => openPickerCalls.push(args), canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -119,6 +123,10 @@ test("always addressing a new agent delegates the first add for immediate confir isInlineMentionSelection: () => false, isMentionOpen: false, canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, }, onAddressAgentMention: (value) => addressedSuggestions.push(value), @@ -159,6 +167,10 @@ 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("@"), }; @@ -220,8 +232,16 @@ test("selecting an already addressed agent from the explicit picker pulses its b 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, @@ -280,8 +300,16 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn 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, @@ -348,6 +376,10 @@ test("selecting a human mention never changes automatic addressing", async () => 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, @@ -401,6 +433,10 @@ test("restoring a multi-word automatic mention into an empty composer focuses af getDraftMentionRefs: () => [], getMentionDisplayName: () => "claude code", canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -451,6 +487,10 @@ test("restoring before authored text preserves its selection", async () => { getDraftMentionRefs: () => [], getMentionDisplayName: () => "Morgarita", canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: () => {}, }, onPulseAddressLock: () => {}, @@ -506,6 +546,10 @@ 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]; @@ -648,8 +692,16 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = 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, @@ -715,8 +767,16 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua ], 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, @@ -830,6 +890,10 @@ test("restoring after an agent rename keeps the existing automatic mention", asy ], getMentionDisplayName: () => displayName, canSelectMention: () => true, + cancelMentionAdmission: () => {}, + admitMention(_row, _cursor, valid, commit) { + if (valid()) commit(); + }, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -912,6 +976,10 @@ test("automatic mention insertion and restoration use the registered collision-s 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); @@ -1012,6 +1080,10 @@ test("inverse deletion and toggle preserve B and exclude A from the composed sen 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); @@ -1091,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 }, ], @@ -1126,6 +1199,10 @@ test("rejected stale selection never pins, tracks, announces or edits", async () 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, diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 7c7212f6a0a..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,6 +88,7 @@ export function useAgentAddressLockPicker({ profiles?: UserProfileLookup; richText: UseRichTextEditorResult; }) { + useMentionAdmissionEditor(richText.editor, mentions.cancelMentionAdmission); const lockedAgentPubkeys = React.useMemo( () => new Set(audience.pubkeys), [audience.pubkeys], @@ -186,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); @@ -220,6 +228,8 @@ export function useAgentAddressLockPicker({ }, [ applyAutocompleteEdit, + mentions.cancelMentionAdmission, + scopeOwner, audience.excludePubkey, audience.removePubkey, audienceScope, @@ -258,74 +268,85 @@ export function useAgentAddressLockPicker({ ) return; + 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 { - if (!mentions.canSelectMention(suggestion)) return; - 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); - } else { - mentions.openMentionPicker(cursor); - } + 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, @@ -337,7 +358,8 @@ export function useAgentAddressLockPicker({ mentions.mentionStartIndex, mentions.openMentionPicker, mentions.registerMentionPubkey, - mentions.canSelectMention, + mentions.admitMention, + mentions.cancelMentionAdmission, scopeOwner, onAddressAgentMention, onImplicitPrefixInserted, @@ -353,40 +375,46 @@ export function useAgentAddressLockPicker({ if (currentAudience.current.scopeOwner !== scopeOwner) return; const { cursor } = richText.getPlainTextAndCursor(); const wasInlineSelection = mentions.isInlineMentionSelection(); - const edit = mentions.insertMention(suggestion, cursor); - // Rejected stale selections must not establish automatic audience intent. - if (!edit.insertText) return; - 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; - } + 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(edit); - 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; - } - applyAutocompleteEdit(edit); + applyAutocompleteEdit(edit); + }, + ); }, [ applyAutocompleteEdit, @@ -394,7 +422,7 @@ export function useAgentAddressLockPicker({ audienceScope, lockedAgentPubkeys, mentions.isInlineMentionSelection, - mentions.insertMention, + mentions.selectMention, scopeOwner, onAddressAgentMention, onAutoPinAgentMention, @@ -504,7 +532,7 @@ export function useAgentAddressLockPicker({ ); return { - announcement, + announcement: mentions.mentionAdmissionStatus || announcement, lockedAgents, lockedAgentPubkeys, removeAddressedAgent, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 40fbe78a43e..c2d72d34c1b 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1814,7 +1814,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(); @@ -2172,6 +2174,457 @@ 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"); + + 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 readCommandLog(page); + + 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 readCommandLog(page); + // 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)).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"); + + 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 readCommandLog(page); + + // 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 readCommandLog(page); + 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)).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"); + + 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 readCommandLog(page); + + // 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 readCommandLog(page); + 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)).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"); +}); + +test("editing and restoring the draft 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"); + + 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 readCommandLog(page); + + 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); + + // Real edits invalidate the pending operation even when text AND caret + // return to their original values before the allowed response arrives. + await page.keyboard.press("Backspace"); + await expect(input).toHaveText("@quin"); + await expect(input).toBeFocused(); + await page.keyboard.type("n"); + 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 readCommandLog(page); + 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)).toBe( + commandCount(baselineCommands, command), + ); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); +}); + test("selected relay agents are invited as bots before sending", async ({ page, }) => { @@ -2270,7 +2723,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(); @@ -2324,7 +2779,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(); diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index aa34b9f1c12..4b69c6c838c 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -248,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", diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 980fa72647a..35fd18da052 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -95,3 +95,47 @@ 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. + +## 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. +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. From 0e1c83e830649ae1319bae8ee1462ee9f7c2e55c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 16:23:10 -0400 Subject: [PATCH 2/9] chore(desktop): remove redundant emoji section heading Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/features/messages/ui/MessageComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index fa87f168548..d6bf8cbc09b 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -513,7 +513,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; From 14c900a343c1e9febb3403aed7c72d0fb85d4fa4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 16:46:52 -0400 Subject: [PATCH 3/9] test(mentions): prove editor cancellation with native range restoration Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/freshMentionChatJourney.test.mjs | 177 +++++++++---- desktop/tests/e2e/mentions.spec.ts | 236 ++++++++++-------- 2 files changed, 256 insertions(+), 157 deletions(-) diff --git a/desktop/src/features/channels/freshMentionChatJourney.test.mjs b/desktop/src/features/channels/freshMentionChatJourney.test.mjs index 9a1335e109d..b63a1ed2227 100644 --- a/desktop/src/features/channels/freshMentionChatJourney.test.mjs +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -239,9 +239,12 @@ function Composer() { { 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)) { + if ( + event.key === "Tab" && + event.shiftKey && + event.target === richText.editor.view.dom && + focusMentionOptionsTrigger(formRef.current) + ) { event.preventDefault(); return; } @@ -258,7 +261,9 @@ function Composer() { React.createElement(MentionAutocomplete, { composerOwnsFocus: true, keepMentionedAgentsPinned: keepPinned, - onKeepMentionedAgentsPinnedChange: state.withOptions ? setKeepPinned : undefined, + onKeepMentionedAgentsPinnedChange: state.withOptions + ? setKeepPinned + : undefined, isOpen: mention.isMentionOpen, isLoading: mention.isMentionLoading, suggestions: mention.suggestions, @@ -495,19 +500,30 @@ 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; }); + 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, - })); + 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"); + assert.equal( + document.activeElement === trigger, + true, + "ShiftTab focuses Options", + ); richText.editor.view.dom.focus(); }); await act(async () => resolve([rawAgent()])); @@ -521,65 +537,110 @@ for (const shiftKey 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}"]`); + 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"); + 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, + 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"); + 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, + 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(); + 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, + 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(); }); - 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(); assert.equal(picker.announcement, ""); } 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.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); + 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 " }], - }], + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "@Remote Scout " }], + }, + ], }); } }); @@ -588,10 +649,16 @@ for (const shiftKey of [false, true]) { 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" }); + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + withOptions: departure !== "no-Options native fallback", + }); let resolve; - state.fresh = new Promise((r) => { resolve = r; }); + state.fresh = new Promise((r) => { + resolve = r; + }); await act(async () => { richText.editor.view.dom.focus(); choose("Enter"); @@ -603,20 +670,38 @@ for (const departure of ["window", "no-Options native fallback"]) { 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"); + 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); + 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, + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, }); editor.dispatchEvent(event); - assert.equal(event.defaultPrevented, false, "no Options leaves ShiftTab native"); + 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"); + assert.equal( + document.activeElement === outside, + true, + "native fallback departs editor", + ); editor.focus(); } }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c2d72d34c1b..1a4385cc4ec 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2343,9 +2343,7 @@ test("navigating to mention Options during a held selection inserts nothing late await expect(input).toBeFocused(); await page.keyboard.press("Shift+Tab"); await expect( - page - .getByTestId("message-composer") - .getByTestId("mention-options-trigger"), + page.getByTestId("message-composer").getByTestId("mention-options-trigger"), ).toBeFocused(); // Release the held authority response and settle the downstream DOM. @@ -2437,9 +2435,9 @@ test("navigating away from the mention row pin during its held revalidation pins 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"); + 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. @@ -2510,120 +2508,136 @@ test("navigating away from the mention row pin during its held revalidation pins `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, ), ).toBeVisible(); - await expect( - page.getByTestId("composer-address-lock-status"), - ).toContainText("Automatically mentioning quinn"); + 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"); }); -test("editing and restoring the draft 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"); - - 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, - }; +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 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 readCommandLog(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); - 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); + 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 readCommandLog(page); - // Real edits invalidate the pending operation even when text AND caret - // return to their original values before the allowed response arrives. - await page.keyboard.press("Backspace"); - await expect(input).toHaveText("@quin"); - await expect(input).toBeFocused(); - await page.keyboard.type("n"); - await expect(input).toHaveText("@quinn"); - await expect(input).toBeFocused(); - await expect.poll(readCaret).toEqual(originalCaret); + 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 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 readCommandLog(page); - 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)).toBe( - commandCount(baselineCommands, command), - ); - } - expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); -}); + 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 readCommandLog(page); + 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)).toBe( + commandCount(baselineCommands, command), + ); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + }); +} test("selected relay agents are invited as bots before sending", async ({ page, From aaa708daac49ad2141dc917f45121dd5257b9fe2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 17:32:10 -0400 Subject: [PATCH 4/9] fix(desktop): bind mention cancellation to mounted editors The owning editor hook now follows the public mount/unmount/destroy lifecycle: cancellation listeners attach only to a live mounted view, detach from the captured DOM on retirement or replacement, and pending admission work is cancelled rather than committing against a destroyed or replaced editor. Adds a real useEditor/EditorContent regression and presence-baselined browser guards for the profile and virtualization routes. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../lib/useMentionAdmissionEditor.test.mjs | 213 ++++++++++++++++++ .../messages/lib/useMentionAdmissionEditor.ts | 36 ++- desktop/tests/e2e/mentions.spec.ts | 123 ++++++++-- 3 files changed, 343 insertions(+), 29 deletions(-) create mode 100644 desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs 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 index 1967beee28e..5b817554c84 100644 --- a/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts @@ -15,20 +15,38 @@ export function useMentionAdmissionEditor( }) => { if (transaction.docChanged || transaction.selectionSet) cancel(); }; - const dom = editor.view.dom; + let dom: HTMLElement | null = null; const keydown = (event: KeyboardEvent) => { if (!["Enter", "Tab", " "].includes(event.key)) cancel(); }; - editor.on("transaction", transaction); - dom.addEventListener("keydown", keydown); - dom.addEventListener("beforeinput", cancel); - dom.addEventListener("pointerdown", cancel); - return () => { + const detach = () => { cancel(); editor.off("transaction", transaction); - dom.removeEventListener("keydown", keydown); - dom.removeEventListener("beforeinput", cancel); - dom.removeEventListener("pointerdown", cancel); + 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/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 1a4385cc4ec..bb73b55440a 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -113,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, @@ -2191,6 +2254,7 @@ test("cached-visible revoked relay agent selection is denied without a directory 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"); @@ -2207,7 +2271,10 @@ test("cached-visible revoked relay agent selection is denied without a directory // The cached directory keeps serving the same eligible row. await expect(quinnRow).toBeVisible(); await expect(quinnRow).toBeEnabled(); - const baselineCommands = await readCommandLog(page); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); await quinnRow.click(); await expect(page.getByTestId("composer-address-lock-status")).toHaveText( @@ -2216,7 +2283,7 @@ test("cached-visible revoked relay agent selection is denied without a directory await expect(input).toHaveText("@quinn"); await expect(input.locator(".mention-chip")).toHaveCount(0); - const deniedCommands = await readCommandLog(page); + 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, @@ -2234,9 +2301,10 @@ test("cached-visible revoked relay agent selection is denied without a directory "send_channel_message", "sign_event", ]) { - expect(commandCount(deniedCommands, command)).toBe( - commandCount(baselineCommands, command), - ); + expect( + commandCount(deniedCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); } expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); @@ -2322,13 +2390,17 @@ test("navigating to mention Options during a held selection inserts nothing late 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 readCommandLog(page); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); // Hold the fresh targeted revalidation, then start the selection normally. await holdMentionGateCommand(page, "revalidate_relay_agents"); @@ -2363,7 +2435,7 @@ test("navigating to mention Options during a held selection inserts nothing late await expect( page.getByTestId("composer-address-lock-status"), ).not.toContainText("Automatically mentioning"); - const settledCommands = await readCommandLog(page); + const settledCommands = await captureMentionCommandBoundary(page, "final"); for (const command of [ "add_channel_members", "start_managed_agent", @@ -2372,9 +2444,10 @@ test("navigating to mention Options during a held selection inserts nothing late "send_channel_message", "sign_event", ]) { - expect(commandCount(settledCommands, command)).toBe( - commandCount(baselineCommands, command), - ); + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); } expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); }); @@ -2396,6 +2469,7 @@ test("navigating away from the mention row pin during its held revalidation pins 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"); @@ -2403,7 +2477,10 @@ test("navigating away from the mention row pin during its held revalidation pins const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); await expect(quinnRow).toBeEnabled(); - const baselineCommands = await readCommandLog(page); + 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 @@ -2476,7 +2553,7 @@ test("navigating away from the mention row pin during its held revalidation pins page.getByTestId("composer-address-lock-status"), ).not.toContainText("Automatically mentioning"); await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "false"); - const settledCommands = await readCommandLog(page); + const settledCommands = await captureMentionCommandBoundary(page, "final"); for (const command of [ "add_channel_members", "start_managed_agent", @@ -2485,9 +2562,10 @@ test("navigating away from the mention row pin during its held revalidation pins "send_channel_message", "sign_event", ]) { - expect(commandCount(settledCommands, command)).toBe( - commandCount(baselineCommands, command), - ); + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); } expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); @@ -2533,6 +2611,7 @@ for (const change of ["draft", "selection range"] as const) { 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"); @@ -2567,7 +2646,10 @@ for (const change of ["draft", "selection range"] as const) { await expect(input).toBeFocused(); const originalCaret = await readCaret(); expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true }); - const baselineCommands = await readCommandLog(page); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); await holdMentionGateCommand(page, "revalidate_relay_agents"); await quinnRow.click(); @@ -2622,7 +2704,7 @@ for (const change of ["draft", "selection range"] as const) { ).toHaveCount(0); await expect(status).not.toContainText("Checking access"); await expect(status).not.toContainText("Automatically mentioning"); - const settledCommands = await readCommandLog(page); + const settledCommands = await captureMentionCommandBoundary(page, "final"); for (const command of [ "add_channel_members", "start_managed_agent", @@ -2631,9 +2713,10 @@ for (const change of ["draft", "selection range"] as const) { "send_channel_message", "sign_event", ]) { - expect(commandCount(settledCommands, command)).toBe( - commandCount(baselineCommands, command), - ); + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); } expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); }); From 0bf787c1aef0d5f0af64c095f02d6a5409821180 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 18:29:53 -0400 Subject: [PATCH 5/9] fix(desktop): abandon pending mentions when live eligibility lapses Keep pending Retry non-actionable while stale query errors remain. Cancel within the existing admission owner when its exact live-choice validity is lost, so later Retry cannot revive an older prepare operation. Preserve the independent 15-second timeout proof on a closed-picker pin, which has no open-request expiry. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/freshMentionChatJourney.test.mjs | 61 +++++++++++++++++-- .../messages/lib/useMentionAdmission.ts | 9 +++ docs/mention-editor.md | 3 + 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/freshMentionChatJourney.test.mjs b/desktop/src/features/channels/freshMentionChatJourney.test.mjs index b63a1ed2227..ad2719d513d 100644 --- a/desktop/src/features/channels/freshMentionChatJourney.test.mjs +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -529,7 +529,6 @@ for (const mode of ["Enter", "pin"]) { await act(async () => resolve([rawAgent()])); await settle(); unchanged(); - assert.equal(picker.announcement, ""); }); } for (const shiftKey of [false, true]) { @@ -608,7 +607,6 @@ for (const shiftKey of [false, true]) { await settle(); if (depart) { unchanged(); - assert.equal(picker.announcement, ""); } else { assert.deepEqual( effects.filter(([kind]) => kind === "promote"), @@ -708,7 +706,6 @@ for (const departure of ["window", "no-Options native fallback"]) { await act(async () => resolve([rawAgent()])); await settle(); unchanged(); - assert.equal(picker.announcement, ""); }); } @@ -990,13 +987,16 @@ for (const mode of ["pointer", "Enter", "Tab", " "]) }); } -test("pin authority timeout is retryable and late allow cannot mutate", async () => { +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 () => choose("pin")); + await act(async () => choosePin()); unchanged(); await act(async () => new Promise((r) => setTimeout(r, 15100))); assert.match(picker.announcement, /Could not check/); @@ -1005,7 +1005,7 @@ test("pin authority timeout is retryable and late allow cannot mutate", async () await settle(); unchanged(); state.fresh = Promise.resolve([rawAgent()]); - await act(async () => choose("pin")); + await act(async () => choosePin()); await settle(); assert.ok(effects.some(([kind]) => kind === "promote")); }); @@ -1080,3 +1080,52 @@ test("standalone forum literal Space and Enter outside chooser retain native dis 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/messages/lib/useMentionAdmission.ts b/desktop/src/features/messages/lib/useMentionAdmission.ts index be940ec8d28..e4171115099 100644 --- a/desktop/src/features/messages/lib/useMentionAdmission.ts +++ b/desktop/src/features/messages/lib/useMentionAdmission.ts @@ -4,6 +4,7 @@ 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, @@ -15,9 +16,15 @@ export function useMentionAdmission(scope: object) { 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(); @@ -39,6 +46,7 @@ export function useMentionAdmission(scope: object) { 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. @@ -92,6 +100,7 @@ export function useMentionAdmission(scope: object) { releaseNavigation.current = undefined; clearTimeout(timer.current); activeKey.current = null; + activeValid.current = null; if (!committing && !operation.valid()) setStatus(""); } } diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 35fd18da052..2fa0aafc550 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -95,6 +95,7 @@ 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 @@ -134,6 +135,8 @@ 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, From c26596866a0e34def676c1b19f51e14e84dcc384 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 18:55:17 -0400 Subject: [PATCH 6/9] fix(desktop): retire claimed automatic composer focus Apply sealed autofocus 5c122833 candidate without speculative refactoring. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + .../lib/scheduleComposerAutofocus.test.mjs | 129 ++++++++++++++++++ .../messages/lib/scheduleComposerAutofocus.ts | 75 ++++++++++ .../messages/lib/useComposerAutofocus.ts | 29 ++-- .../features/messages/ui/MessageComposer.tsx | 2 +- desktop/tests/e2e/composer-autofocus.spec.ts | 104 ++++++++++++++ 6 files changed, 318 insertions(+), 22 deletions(-) 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/tests/e2e/composer-autofocus.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 516eafcc57b..b385f64d0e4 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ "**/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/messages/lib/scheduleComposerAutofocus.test.mjs b/desktop/src/features/messages/lib/scheduleComposerAutofocus.test.mjs new file mode 100644 index 00000000000..fe5f39e5be7 --- /dev/null +++ b/desktop/src/features/messages/lib/scheduleComposerAutofocus.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { JSDOM } from "jsdom"; +import { scheduleComposerAutofocus } from "./scheduleComposerAutofocus.ts"; + +for (const scenario of [ + "initial", + "pointer", + "keyboard", + "focus", + "retired", + "destroyed", + "disabled", + "navigation", + "explicit", + "iOS", + "Android", + "Safari", +]) { + test(`automatic focus commit: ${scenario}`, () => { + const dom = new JSDOM( + '
', + { 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/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d6bf8cbc09b..a5049bf8154 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -438,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( 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(); + } + }); +} From 524d92cd2668e2708fef526cc6b4be5b2265f60d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 19:08:03 -0400 Subject: [PATCH 7/9] test(desktop): await native block selection before typing Apply sealed 6492 test-only readiness correction; production is unchanged. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../e2e/composer-selection-formatting.spec.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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( From 3a062ea7f24ea2af464724f4aa56d0a232b387f2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 19:17:47 -0400 Subject: [PATCH 8/9] test(desktop): prove native deletion before draft scope cleanup Capture the real editor document without polling before the rapid switch; add persisted-first controls without replacing unsaved coverage. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../tests/e2e/remote-owned-mentions.spec.ts | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index 4b69c6c838c..7784239baa9 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -464,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); @@ -525,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) => @@ -538,12 +541,44 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { throw new Error("control B draft missing"); return drafts[`thread:${root}`] ?? null; }, roots); + 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"); + } + await input.fill(""); expect(await sourceRecord()).toBeNull(); - // Expando proves the actual editor DOM host survived A -> B. - await input.evaluate((el) => - el.setAttribute("data-lifecycle-host", "retained"), - ); + // Read, don't poll: use the existing expando round trip to capture the + // actual PM document after native deletion. Assert only AFTER switching, + // so proof of deletion cannot wait away a rapid scope-cleanup race. + const deleted = await input.evaluate((el) => { + el.setAttribute("data-lifecycle-host", "retained"); + const editor = ( + el as HTMLElement & { editor: import("@tiptap/core").Editor } + ).editor; + return { + dom: el.textContent, + doc: editor.getJSON(), + from: editor.state.selection.from, + to: editor.state.selection.to, + }; + }); await navigate(roots[1]); + expect(deleted).toEqual({ + dom: "", + doc: { type: "doc", content: [{ type: "paragraph" }] }, + from: 1, + to: 1, + }); await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); await expect(input).toHaveText(incoming); await expect(page.getByRole("alertdialog")).toHaveCount(0); From 2ecd7631f440116a7401766ae5c4a7d8702b5401 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 19:59:12 -0400 Subject: [PATCH 9/9] test(desktop): witness native deletion update before scope switch Observe the first real update from authored text and capture persistence after the production callback, then switch before asserting. Retain unsaved and persisted controls. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../tests/e2e/remote-owned-mentions.spec.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index 7784239baa9..b9d5e9efb8d 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -555,30 +555,53 @@ for (const { incoming, savedFirst } of [ await navigate(roots[0]); await expect(input).toHaveText("new authored text"); } - await input.fill(""); - expect(await sourceRecord()).toBeNull(); - // Read, don't poll: use the existing expando round trip to capture the - // actual PM document after native deletion. Assert only AFTER switching, - // so proof of deletion cannot wait away a rapid scope-cleanup race. - const deleted = await input.evaluate((el) => { + // 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 { - dom: el.textContent, - doc: editor.getJSON(), - from: editor.state.selection.from, - to: editor.state.selection.to, + 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);