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/channels/freshMentionChatJourney.test.mjs b/desktop/src/features/channels/freshMentionChatJourney.test.mjs new file mode 100644 index 00000000000..ad2719d513d --- /dev/null +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -0,0 +1,1131 @@ +import { closeHistory } from "@tiptap/pm/history"; +import { + getMentionSelectionHistory, + resetMentionSelectionHistory, +} from "../messages/lib/mentionSelectionHistory.ts"; +// Production mention + CHAT picker + native Tiptap boundary; only IPC is fixture evidence. +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +// JSDOM has no layout; geometry is not an admission or input-dispatch fixture. +dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +dom.window.Range.prototype.getClientRects = () => []; +dom.window.Range.prototype.getBoundingClientRect = () => + new dom.window.DOMRect(); +// Radix tooltip focus-open runs document.dispatchEvent(new +// CustomEvent(TOOLTIP_OPEN)) against the ambient global; Node's CustomEvent is a +// foreign realm to this jsdom document, so install the jsdom constructor and +// restore Node's original in the after() teardown. +const originalCustomEvent = globalThis.CustomEvent; +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + CustomEvent: dom.window.CustomEvent, + localStorage: dom.window.localStorage, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + getComputedStyle: dom.window.getComputedStyle, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + MutationObserver: dom.window.MutationObserver, + IS_REACT_ACT_ENVIRONMENT: true, + self: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +dom.window.cancelAnimationFrame = clearTimeout; +globalThis.cancelAnimationFrame = clearTimeout; +const VIEWER = "a".repeat(64), + AGENT = "b".repeat(64), + OTHER = "c".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; +localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "test", + name: "Test", + relayUrl: "ws://test.invalid", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), +); +localStorage.setItem("buzz-active-community-id", "test"); +let state; +const channel = () => ({ + id: CHANNEL, + name: "fresh", + channel_type: "stream", + visibility: "open", + description: "", + is_member: true, + archived_at: null, + member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER], + member_count: state.visible ? 2 : 1, + participant_pubkeys: [], + participants: [], + last_message_at: null, + ttl_seconds: null, + ttl_deadline: null, +}); +const rawAgent = () => ({ + pubkey: AGENT, + owner_pubkey: state.owner, + name: "Remote Scout", + agent_type: "agent", + channels: [], + channel_ids: state.directoryVisible ? [CHANNEL] : [], + capabilities: [], + status: "offline", + respond_to: state.policy, + respond_to_allowlist: [], +}); +const invoke = async (command, args) => { + if (command.startsWith("plugin:event|")) return 0; + if (command === "search_users") { + if (state.pendingSearch?.[args.query]) + return state.pendingSearch[args.query]; + return { users: state.searchUsers ?? [], next_cursor: null }; + } + if (command === "get_identity") return { pubkey: VIEWER }; + if (command === "create_channel") return channel(); + if (command === "get_channels") + return { + channels: [channel()], + hash: String(state.visible), + last_messages: [], + }; + if (command === "get_channel_members" && state.heldRoster) + return state.heldRoster; + if (command === "get_channel_members") + return { + members: [ + { + pubkey: VIEWER, + role: "owner", + display_name: "Viewer", + is_agent: false, + }, + ...(state.visible + ? [ + { + pubkey: AGENT, + role: state.role, + display_name: "Remote Scout", + is_agent: true, + }, + ] + : []), + ], + }; + if (command === "add_channel_members") { + assert.equal(args.channelId, CHANNEL); + assert.equal(args.role, state.role); + state.accepted = true; + return state.addResult; + } + if (command === "sync_agents_to_active_huddle") return null; + if (command === "list_relay_agents") { + state.directoryCalls += 1; + if (state.heldDirectory) return state.heldDirectory; + if (state.failDirectory) throw new Error("Directory unavailable"); + return state.missingDirectory ? [] : [rawAgent()]; + } + if (command === "revalidate_relay_agents") { + state.freshCalls = (state.freshCalls ?? 0) + 1; + assert.deepEqual(args.pubkeys, [AGENT]); + assert.equal(args.channelId, state.channelId); + if (state.fresh) return state.fresh; + if (state.failFresh) throw new Error("offline"); + return state.missingDirectory ? [] : [rawAgent()]; + } + if (["list_managed_agents", "list_personas", "list_teams"].includes(command)) + return []; + if (command === "get_users_batch") return { profiles: {}, missing: [] }; + if (command === "list_archived_identities") return { archived: [] }; + throw new Error(`Unexpected IPC: ${command}`); +}; +globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 }; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; +dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__; + +let React, + act, + createRoot, + QueryClient, + QueryClientProvider, + CommunitiesProvider; +let useMentions, useRichTextEditor, EditorContent, richText; +let root, client, mention, picker, focusMentionOptionsTrigger; +let useAgentAddressLockPicker, effects, MentionAutocomplete, TooltipProvider; +before(async () => { + ({ MentionAutocomplete, focusMentionOptionsTrigger } = await import( + "@/features/messages/ui/MentionAutocomplete.tsx" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); + ({ useAgentAddressLockPicker } = await import( + "@/features/messages/ui/useAgentAddressLockPicker.ts" + )); + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ useRichTextEditor } = await import( + "@/features/messages/lib/useRichTextEditor.ts" + )); + ({ EditorContent } = await import("@tiptap/react")); + ({ useMentions } = await import("@/features/messages/lib/useMentions.ts")); +}); +function Composer() { + const open = React.useRef(false); + const formRef = React.useRef(null); + const [keepPinned, setKeepPinned] = React.useState(true); + richText = useRichTextEditor({ + isAutocompleteOpen: open, + onSubmit: () => effects.push(["submit"]), + onUpdate: ({ text, cursor }) => mention?.updateMentionQuery(text, cursor), + onSelectionUpdate: ({ text, cursor }) => + mention?.updateMentionQuery(text, cursor), + }); + mention = useMentions(state.channelId, undefined, undefined, { + channelType: "stream", + getEditorSnapshot: richText.getPlainTextAndCursor, + }); + open.current = mention.isMentionOpen; + picker = useAgentAddressLockPicker({ + mentions: mention, + audience: { + pubkeys: state.locked, + addPubkey: (key) => effects.push(["pin", key]), + removePubkey: (key) => effects.push(["remove", key]), + }, + audienceScope: state.channelId, + richText, + applyAutocompleteEdit: (edit) => { + effects.push(["edit", edit]); + richText.replacePlainTextRange( + edit.replaceFromOffset, + edit.replaceToOffset, + edit.insertText, + undefined, + edit.preserveSelection, + edit.reassertMentionCaret, + ); + }, + onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]), + onAutoPinAgentMention: (row) => effects.push(["autoPin", row.pubkey]), + onImplicitPrefixInserted: (refs) => effects.push(["provenance", refs]), + onPulseAddressLock: () => effects.push(["pulse"]), + }); + return React.createElement( + "div", + { ref: formRef }, + React.createElement( + "div", + { + onKeyDown: (event) => { + // Only editor events reach this bridge, as in MessageComposer. + if ( + event.key === "Tab" && + event.shiftKey && + event.target === richText.editor.view.dom && + focusMentionOptionsTrigger(formRef.current) + ) { + event.preventDefault(); + return; + } + const result = mention.handleMentionKeyDown(event); + if (result.suggestion) + picker.selectMentionSuggestion(result.suggestion); + }, + }, + React.createElement(EditorContent, { editor: richText.editor }), + ), + React.createElement( + TooltipProvider, + null, + React.createElement(MentionAutocomplete, { + composerOwnsFocus: true, + keepMentionedAgentsPinned: keepPinned, + onKeepMentionedAgentsPinnedChange: state.withOptions + ? setKeepPinned + : undefined, + isOpen: mention.isMentionOpen, + isLoading: mention.isMentionLoading, + suggestions: mention.suggestions, + selectedIndex: mention.mentionSelectedIndex, + onSelect: picker.selectMentionSuggestion, + onToggleAlwaysAddressAgent: picker.toggleAlwaysAddressAgent, + alwaysAddressedAgentPubkeys: new Set(state.locked), + }), + ), + ); +} + +async function render(withComposer = true) { + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + withComposer ? React.createElement(Composer) : null, + ), + ), + ), + ); +} +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + // React Query notification batching may be enqueued by effects committed above. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} +const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT); +async function setup(overrides = {}) { + effects = []; + state = { + locked: [], + withOptions: true, + channelId: CHANNEL, + role: "bot", + owner: VIEWER, + policy: "anyone", + accepted: false, + visible: false, + directoryVisible: false, + directoryCalls: 0, + addResult: { added: [AGENT], errors: [] }, + ...overrides, + }; + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + for (const [key, data] of [ + [["identity"], { pubkey: VIEWER }], + [["channels"], [channel()]], + [["managed-agents"], []], + [["personas"], []], + [["teams"], []], + [["archivedIdentities"], { archived: [] }], + ]) + if ( + !( + (state.heldDirectory || state.coldDirectory) && + key[0] === "relay-agents" + ) + ) + client.setQueryData(key, data); + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await render(); + await settle(); + await act(async () => { + richText.setContent("@"); + richText.editor.commands.setTextSelection(2); + mention.updateMentionQuery("@", 1); + }); + await settle(); + for ( + let i = 0; + i < mention.suggestions.length && + mention.suggestions[mention.mentionSelectedIndex]?.pubkey !== AGENT; + i++ + ) { + await act(async () => choose("ArrowDown")); + } + assert.equal( + mention.suggestions[mention.mentionSelectedIndex]?.pubkey, + AGENT, + ); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + resetMentionSelectionHistory(); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => { + globalThis.CustomEvent = originalCustomEvent; + dom.window.close(); +}); + +function unchanged() { + assert.equal(richText.getPlainTextAndCursor().text, "@"); + assert.deepEqual(mention.getDraftMentionRefs("@Remote Scout "), []); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.agentKnownNames, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.deepEqual(effects, []); +} +function choose(mode) { + if (mode === "pin") { + const toggle = document.querySelector( + `[data-testid="mention-always-address-${AGENT}"]`, + ); + assert.ok(toggle, "production pin Toggle"); + toggle.click(); + } else if (mode === "pointer") picker.selectMentionSuggestion(rows()[0]); + else { + const event = new dom.window.KeyboardEvent("keydown", { + key: mode, + bubbles: true, + cancelable: true, + }); + richText.editor.view.dom.dispatchEvent(event); + assert.equal(event.defaultPrevented, true); + } +} +for (const mode of ["pointer", "Enter", "Tab", "pin"]) { + for (const outcome of ["allow", "revoke", "failure"]) { + test(`${mode}: fresh ${outcome} is atomic without discovery refresh`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(mode)); + assert.match(picker.announcement, /Checking/); + unchanged(); + assert.equal(state.freshCalls, 1); + if (outcome === "revoke") state.policy = "owner-only"; + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + if (outcome === "allow") { + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); + assert.equal( + effects.filter(([kind]) => kind === "edit").length, + mode === "pin" ? 2 : 1, + ); + assert.equal( + effects.filter( + ([kind]) => kind === (mode === "pin" ? "promote" : "autoPin"), + ).length, + 1, + ); + assert.equal( + mention.getDraftMentionRefs("@Remote Scout ")[0].pubkey, + AGENT, + ); + assert.equal( + getMentionSelectionHistory(VIEWER, CHANNEL).length, + mode === "pin" ? 0 : 1, + ); + } else { + unchanged(); + assert.match( + picker.announcement, + outcome === "revoke" ? /Access changed/ : /Could not check/, + ); + } + }); + } +} +for (const change of [ + "edit-undo", + "caret-return", + "scope-return", + "unmount", + "Escape", + "ArrowDown", +]) { + test(`late allow after ${change} cannot commit`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("Enter")); + unchanged(); + if (change === "scope-return") { + state.channelId = "other"; + await render(); + state.channelId = CHANNEL; + await render(); + } else if (change === "unmount") await render(false); + else + await act(async () => { + if (change === "edit-undo") { + richText.editor.view.dispatch(closeHistory(richText.editor.state.tr)); + richText.editor.commands.insertContent("x"); + richText.editor.commands.undo(); + assert.equal(richText.getPlainTextAndCursor().text, "@"); + } else if (change === "caret-return") { + richText.editor.commands.setTextSelection(1); + richText.editor.commands.setTextSelection(2); + } else choose(change); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (change !== "unmount") + assert.doesNotMatch(picker.announcement, /Checking/); + }); +} + +// JSDOM does not perform native Tab movement or keyboard-generated clicks. +// Dispatch the key, then enact that browser default explicitly; the focus and +// production admission/picker/editor effects are real, not mocked cancellation. +for (const mode of ["Enter", "pin"]) { + test(`navigation: editor ShiftTab abandons ${mode} even after return`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => { + richText.editor.view.dom.focus(); + choose(mode); + }); + unchanged(); + await act(async () => { + richText.editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + const trigger = document.querySelector("[data-mention-options-trigger]"); + assert.ok(trigger, "chat Options trigger exists"); + assert.equal( + document.activeElement === trigger, + true, + "ShiftTab focuses Options", + ); + richText.editor.view.dom.focus(); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + }); +} +for (const shiftKey of [false, true]) { + for (const depart of [false, true]) { + test(`navigation: focused pin ${shiftKey ? "ShiftTab" : "Tab"} depart=${depart}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + const toggle = document.querySelector( + `[data-testid="mention-always-address-${AGENT}"]`, + ); + assert.ok(toggle, "production pin Toggle exists"); + await act(async () => { + toggle.focus(); + assert.equal( + document.activeElement === toggle, + true, + "keyboard pin owns focus", + ); + const key = shiftKey ? " " : "Enter"; + const event = new dom.window.KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + toggle.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "overlay activation stays native", + ); + assert.equal( + state.freshCalls ?? 0, + 0, + "pin keydown must not select in editor", + ); + const release = new dom.window.KeyboardEvent("keyup", { + key, + bubbles: true, + cancelable: true, + }); + // JSDOM lacks native activation: Enter clicks on keydown, Space on keyup. + if (key === " ") toggle.dispatchEvent(release); + if (!event.defaultPrevented && !release.defaultPrevented) + toggle.click(); + }); + unchanged(); + assert.equal(state.freshCalls, 1); + if (depart) + await act(async () => { + const event = new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey, + bubbles: true, + cancelable: true, + }); + toggle.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "overlay Tab stays native", + ); + const outside = document.createElement("button"); + document.body.append(outside); + if (!event.defaultPrevented) outside.focus(); + assert.equal( + document.activeElement === outside, + true, + "Tab departs pin", + ); + toggle.focus(); + }); + await act(async () => resolve([rawAgent()])); + await settle(); + if (depart) { + unchanged(); + } else { + assert.deepEqual( + effects.filter(([kind]) => kind === "promote"), + [["promote", AGENT]], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "autoPin"), + [], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "provenance"), + [["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]]], + ); + assert.equal(effects.filter(([kind]) => kind === "edit").length, 2); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); + assert.equal( + mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey, + AGENT, + ); + // MentionHighlightExtension decorates literal @labels from either path; + // .mention-chip is not selection provenance. The pin witnesses above + // distinguish admission paths; verify the prefix is plain document text + // (useRichTextEditor.replacePlainTextRange), not an embedded mention node. + assert.deepEqual(richText.editor.getJSON(), { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "@Remote Scout " }], + }, + ], + }); + } + }); + } +} + +for (const departure of ["window", "no-Options native fallback"]) { + test(`navigation: ${departure} abandons pending selection after return`, async () => { + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + withOptions: departure !== "no-Options native fallback", + }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => { + richText.editor.view.dom.focus(); + choose("Enter"); + }); + unchanged(); + assert.equal(state.freshCalls, 1); + await act(async () => { + const editor = richText.editor.view.dom; + if (departure === "window") { + // Window departure can retain activeElement; dispatch only that boundary. + dom.window.dispatchEvent(new dom.window.Event("blur")); + assert.equal( + document.activeElement === editor, + true, + "window blur retains editor identity", + ); + dom.window.dispatchEvent(new dom.window.Event("focus")); + } else { + assert.equal( + document.querySelector("[data-mention-options-trigger]") === null, + true, + ); + const event = new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "no Options leaves ShiftTab native", + ); + // Explicit native Tab default emulation, not a browser tab-order claim. + const outside = document.createElement("button"); + document.body.append(outside); + if (!event.defaultPrevented) outside.focus(); + assert.equal( + document.activeElement === outside, + true, + "native fallback departs editor", + ); + editor.focus(); + } + }); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + }); +} + +test("literal Space remains native, normal Enter submits outside chooser", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const event = new dom.window.KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + await act(async () => richText.editor.view.dom.dispatchEvent(event)); + assert.equal(event.defaultPrevented, false); + assert.equal(state.freshCalls ?? 0, 0); + await act(async () => mention.cancelMentionAutocomplete()); + await act(async () => choose("Enter")); + assert.deepEqual(effects, [["submit"]]); +}); +test("repeat Enter while pending consumes input without another lookup", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("Enter")); + await act(async () => choose("Enter")); + unchanged(); + assert.equal(state.freshCalls, 1); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.equal(effects.filter(([kind]) => kind === "edit").length, 1); +}); + +test("exact Space enters the same fresh operation", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => { + richText.setContent("@Remote Scout"); + richText.editor.commands.setTextSelection(14); + mention.updateMentionQuery("@Remote Scout", 13); + }); + await settle(); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(" ")); + assert.match(picker.announcement, /Checking/); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout"); + assert.deepEqual(effects, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + assert.equal(state.freshCalls, 1); + await act(async () => resolve([rawAgent()])); + await settle(); + assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); +}); +test("late lookup rejection after native input cancellation is silent", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let reject; + state.fresh = new Promise((_resolve, r) => { + reject = r; + }); + await act(async () => choose("Enter")); + await act(async () => + richText.editor.view.dom.dispatchEvent( + new dom.window.InputEvent("beforeinput", { + bubbles: true, + inputType: "insertText", + data: "x", + }), + ), + ); + await act(async () => reject(new Error("late offline"))); + await settle(); + unchanged(); + assert.equal(picker.announcement, ""); +}); + +for (const change of ["edit-undo", "unpin", "failure-retry"]) { + test(`pin pending: ${change}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose("pin")); + unchanged(); + if (change === "edit-undo") { + await act(async () => { + richText.editor.view.dispatch(closeHistory(richText.editor.state.tr)); + richText.editor.commands.insertContent("x"); + richText.editor.commands.undo(); + assert.equal(richText.getPlainTextAndCursor().text, "@"); + }); + } else if (change === "unpin") { + state.locked = [AGENT]; + await render(); + await act(async () => picker.toggleAlwaysAddressAgent(rows()[0])); + assert.ok(effects.some(([kind]) => kind === "remove")); + effects = []; + } + await act(async () => + resolve( + change === "failure-retry" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (change === "failure-retry") { + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choose("pin")); + await settle(); + assert.equal(state.freshCalls, 2); + assert.ok(effects.some(([kind]) => kind === "promote")); + } + }); +} + +async function setupForum(text = "@") { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const { ForumComposer } = await import( + "@/features/forum/ui/ForumComposer.tsx" + ); + const { createRouter, createRootRoute, createMemoryHistory, RouterProvider } = + await import("@tanstack/react-router"); + const { TooltipProvider } = await import("@/shared/ui/tooltip.tsx"); + const route = createRootRoute({ + component: () => + React.createElement( + TooltipProvider, + null, + React.createElement(ForumComposer, { + channelId: CHANNEL, + channelType: "forum", + onSubmit: async (...args) => effects.push(["submit", ...args]), + }), + ), + }); + const router = createRouter({ + routeTree: route, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(RouterProvider, { router }), + ), + ), + ), + ); + await settle(); + const element = document.querySelector(".tiptap"); + const editor = element.editor; + assert.ok(editor, "actual standalone ForumComposer Tiptap editor"); + await act(async () => { + editor.commands.setContent(text); + editor.commands.setTextSelection(text.length + 1); + editor.view.focus(); + }); + await settle(); + return editor; +} +for (const mode of ["pointer", "Enter", "Tab", " "]) + for (const outcome of ["allow", "revoke", "failure", "cancel"]) { + test(`standalone forum production component ${mode}: ${outcome}`, async () => { + const draft = mode === " " ? "@Remote Scout" : "@"; + const editor = await setupForum(draft); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + const row = document.querySelector( + `[data-testid="mention-suggestion-${AGENT}"]`, + ); + assert.ok(row, "displayed exact agent row"); + await act(async () => { + if (mode === "pointer") + row.querySelector("button").dispatchEvent( + new dom.window.MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }), + ); + else { + for (let i = 0; i < Number(row.dataset.mentionSuggestionIndex); i++) + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ); + } + }); + if (mode !== "pointer") + await act(async () => + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: mode, + bubbles: true, + cancelable: true, + }), + ), + ); + assert.equal(editor.getText(), draft); + assert.match(document.querySelector("output").textContent, /Checking/); + assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); + if (outcome === "revoke") state.policy = "owner-only"; + if (outcome === "cancel") + await act(async () => { + editor.commands.setTextSelection(1); + editor.commands.setTextSelection(2); + }); + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.equal( + editor.getText(), + outcome === "allow" ? "@Remote Scout " : draft, + ); + assert.equal( + getMentionSelectionHistory(VIEWER, CHANNEL).length, + outcome === "allow" ? 1 : 0, + ); + assert.deepEqual(effects, []); + if (outcome === "revoke" || outcome === "failure") { + assert.match( + document.querySelector("output").textContent, + outcome === "revoke" ? /Access changed/ : /Could not check/, + ); + state.policy = "anyone"; + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => + row.querySelector("button").dispatchEvent( + new dom.window.MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(editor.getText(), "@Remote Scout "); + assert.equal(state.freshCalls, 2); + } + if (outcome === "allow") { + state.policy = "owner-only"; + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => + document.querySelector("form").dispatchEvent( + new dom.window.Event("submit", { + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(editor.getText(), "@Remote Scout "); + assert.equal( + state.freshCalls, + 2, + "publication must check authority independently", + ); + assert.deepEqual(effects, []); + } + }); + } + +test("closed-picker pin authority timeout is retryable and late allow cannot mutate", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => mention.cancelMentionAutocomplete()); + const choosePin = () => + picker.toggleAlwaysAddressAgent(mention.getDefaultAgentSuggestion()); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choosePin()); + unchanged(); + await act(async () => new Promise((r) => setTimeout(r, 15100))); + assert.match(picker.announcement, /Could not check/); + unchanged(); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choosePin()); + await settle(); + assert.ok(effects.some(([kind]) => kind === "promote")); +}); + +for (const outcome of ["allow", "revoke", "failure", "cancel"]) { + test(`closed-picker default pin: ${outcome}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + await act(async () => { + richText.setContent(""); + mention.cancelMentionAutocomplete(); + }); + await settle(); + const row = mention.getDefaultAgentSuggestion(); + assert.equal(row.pubkey, AGENT); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => picker.toggleAlwaysAddressAgent(row)); + assert.match(picker.announcement, /Checking/); + assert.deepEqual(effects, []); + if (outcome === "revoke") state.policy = "owner-only"; + if (outcome === "cancel") + await act(async () => + richText.editor.view.dom.dispatchEvent( + new dom.window.InputEvent("beforeinput", { bubbles: true }), + ), + ); + await act(async () => + resolve( + outcome === "failure" + ? Promise.reject(new Error("offline")) + : [rawAgent()], + ), + ); + await settle(); + assert.equal( + richText.getPlainTextAndCursor().text, + outcome === "allow" ? "@Remote Scout " : "", + ); + if (outcome !== "allow") { + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); + } + }); +} + +test("standalone forum literal Space and Enter outside chooser retain native dispatch", async () => { + const editor = await setupForum("@Rem"); + const space = new dom.window.KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }); + await act(async () => editor.view.dom.dispatchEvent(space)); + assert.equal(space.defaultPrevented, false); + assert.equal(state.freshCalls, undefined); + await act(async () => { + editor.commands.setContent("ordinary text"); + editor.commands.setTextSelection(14); + }); + await settle(); + await act(async () => + editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ), + ); + await settle(); + assert.equal(effects.filter(([kind]) => kind === "submit").length, 1); +}); + +for (const mode of ["Enter", "pin"]) { + test(`${mode}: directory failure and Retry cannot revive older fresh admission`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const identityOrder = mention.suggestions.map((row) => row.pubkey); + let resolve; + state.fresh = new Promise((r) => { + resolve = r; + }); + await act(async () => choose(mode)); + unchanged(); + state.failDirectory = true; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + await act(async () => rows()[0].onRetry()); + await settle(); + assert.equal(rows()[0].action, "unavailable"); + unchanged(); + state.failDirectory = false; + let releaseDirectory; + state.heldDirectory = new Promise((r) => { + releaseDirectory = r; + }); + await act(async () => rows()[0].onRetry()); + assert.equal(rows()[0].action, "checking"); + unchanged(); + state.heldDirectory = null; + await act(async () => releaseDirectory([rawAgent()])); + await settle(); + assert.equal(rows()[0].action, "mention"); + assert.deepEqual( + mention.suggestions.map((row) => row.pubkey), + identityOrder, + ); + await act(async () => resolve([rawAgent()])); + await settle(); + unchanged(); + state.fresh = Promise.resolve([rawAgent()]); + await act(async () => choose(mode)); + await settle(); + assert.equal(state.freshCalls, 2); + assert.ok( + effects.some(([kind]) => kind === (mode === "pin" ? "promote" : "edit")), + ); + }); +} diff --git a/desktop/src/features/channels/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/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/lib/useMentionAdmission.ts b/desktop/src/features/messages/lib/useMentionAdmission.ts new file mode 100644 index 00000000000..e4171115099 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmission.ts @@ -0,0 +1,112 @@ +import * as React from "react"; +import { AgentMentionAuthorizationError } from "./agentMentionRevalidation"; + +/** One cancellable prepare/commit owner. No caller may mutate while preparing. */ +export function useMentionAdmission(scope: object) { + const activeKey = React.useRef(null); + const activeValid = React.useRef<(() => boolean) | null>(null); + const generation = React.useRef(0); + const timer = React.useRef | undefined>( + undefined, + ); + const releaseNavigation = React.useRef<(() => void) | undefined>(undefined); + const [status, setStatus] = React.useState(""); + const cancel = React.useCallback(() => { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + generation.current += 1; + activeKey.current = null; + activeValid.current = null; + clearTimeout(timer.current); + setStatus(""); + }, []); + // Losing live eligibility abandons this operation, even if Retry later + // restores the same row before its older prepare promise settles. + React.useLayoutEffect(() => { + if (activeValid.current && !activeValid.current()) cancel(); + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: scope changes abandon the operation even when the value returns later. + React.useLayoutEffect(() => { + cancel(); + return () => { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + generation.current += 1; + clearTimeout(timer.current); + }; + }, [scope, cancel]); + const begin = React.useCallback( + (operation: { + key: object; + valid: () => boolean; + prepare: () => Promise; + commit: () => void; + }) => { + if (activeKey.current === operation.key) return; + cancel(); + if (!operation.valid()) return; + activeKey.current = operation.key; + activeValid.current = operation.valid; + // Admission belongs to the focused action, not the composer's wider + // focus ownership. Observe its departure even inside an overlay/portal; + // returning later must not resurrect the pending operation. + const origin = document.activeElement; + const view = origin?.ownerDocument.defaultView; + origin?.addEventListener("blur", cancel); + view?.addEventListener("blur", cancel); + releaseNavigation.current = () => { + origin?.removeEventListener("blur", cancel); + view?.removeEventListener("blur", cancel); + }; + const id = generation.current; + const current = () => id === generation.current && operation.valid(); + setStatus("Checking access…"); + timer.current = setTimeout(() => { + if (id !== generation.current) return; + const valid = operation.valid(); + cancel(); + if (valid) setStatus("Could not check access. Select again to retry."); + }, 15000); + void (async () => { + let committing = false; + try { + await operation.prepare(); + if (!current()) return; + clearTimeout(timer.current); + setStatus(""); + // No await between the final fence and the complete consumer commit. + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + committing = true; + operation.commit(); + } catch (error) { + if (committing) { + console.error("Mention selection commit failed", error); + setStatus( + "Could not finish selection. Check the draft before retrying.", + ); + return; + } + if (!current()) return; + setStatus( + error instanceof AgentMentionAuthorizationError && + error.reason === "denied" + ? "Access changed. Selection was not inserted. Select again to retry." + : "Could not check access. Select again to retry.", + ); + } finally { + if (id === generation.current) { + releaseNavigation.current?.(); + releaseNavigation.current = undefined; + clearTimeout(timer.current); + activeKey.current = null; + activeValid.current = null; + if (!committing && !operation.valid()) setStatus(""); + } + } + })(); + }, + [cancel], + ); + return { begin, cancel, status }; +} diff --git a/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs b/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs new file mode 100644 index 00000000000..86b3e321312 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + for (const key of [ + "window", + "document", + "DOMParser", + "Element", + "HTMLElement", + "Node", + "MutationObserver", + "Event", + "KeyboardEvent", + ]) + globalThis[key] = dom.window[key]; + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; +}); +afterEach(async () => (await import("@testing-library/react")).cleanup()); +after(() => dom.window.close()); + +async function tools() { + return { + React: await import("react"), + ...(await import("@testing-library/react")), + ...(await import("@tiptap/react")), + StarterKit: (await import("@tiptap/starter-kit")).default, + ...(await import("./useMentionAdmissionEditor.ts")), + }; +} + +test("useEditor replacement can retire the captured editor before the admission effect", async () => { + const { + React, + render, + act, + useEditor, + EditorContent, + StarterKit, + useMentionAdmissionEditor, + } = await tools(); + const instances = []; + function Harness({ revision }) { + const editor = useEditor({ extensions: [StarterKit] }, [revision]); + if (editor && !instances.includes(editor)) instances.push(editor); + const cancel = React.useCallback(() => {}, [revision]); + useMentionAdmissionEditor(editor, cancel); + return React.createElement(EditorContent, { editor }); + } + const mounted = render(React.createElement(Harness, { revision: 0 })); + await act(async () => + mounted.rerender(React.createElement(Harness, { revision: 1 })), + ); + assert.equal(instances.length, 2); + assert.equal(instances[0].isDestroyed, true); + assert.equal(instances[1].isDestroyed, false); +}); + +test("real mount/unmount/remount and replacement retain guards without stale DOM listeners", async () => { + const { React, render, Editor, StarterKit, useMentionAdmissionEditor } = + await tools(); + const editor = new Editor({ + element: null, + extensions: [StarterKit], + content: "hello", + // The chooser consumes these keys in production. Keep ProseMirror's + // default Enter edit separate from the native cancellation assertion. + editorProps: { handleKeyDown: () => true }, + }); + const replacement = new Editor({ + element: null, + extensions: [StarterKit], + editorProps: { handleKeyDown: () => true }, + }); + let cancellations = 0; + const cancel = () => { + cancellations++; + }; + function Harness({ editor }) { + useMentionAdmissionEditor(editor, cancel); + return null; + } + const mounted = render(React.createElement(Harness, { editor })); + function guarded(instance) { + const element = instance.view.dom; + for (const type of ["beforeinput", "pointerdown"]) { + const before = cancellations; + element.dispatchEvent(new Event(type)); + assert.equal(cancellations, before + 1, type); + } + for (const key of ["Enter", "Tab", " ", "ArrowLeft", "Escape", "x"]) { + const before = cancellations; + element.dispatchEvent(new KeyboardEvent("keydown", { key })); + assert.equal( + cancellations, + before + (["Enter", "Tab", " "].includes(key) ? 0 : 1), + key, + ); + } + let before = cancellations; + instance.view.dispatch(instance.state.tr); + assert.equal(cancellations, before, "no-op transaction"); + instance.commands.setTextSelection(1); + assert.equal(cancellations, before + 1, "explicit selection transaction"); + before = cancellations; + instance.commands.insertContent("a"); + assert.equal(cancellations, before + 1, "document transaction"); + return element; + } + function inert(element) { + const before = cancellations; + for (const type of ["beforeinput", "pointerdown"]) + element.dispatchEvent(new Event(type)); + element.dispatchEvent(new KeyboardEvent("keydown", { key: "x" })); + assert.equal( + cancellations, + before, + "retired DOM has no admission listeners", + ); + } + try { + assert.throws(() => editor.view.dom, /editor view is not available/); + editor.mount(document.createElement("div")); + const first = guarded(editor); + let before = cancellations; + editor.unmount(); + assert.ok(cancellations > before, "unmount invalidates pending work"); + inert(first); + editor.mount(document.createElement("div")); + const second = guarded(editor); + assert.notEqual(first, second); + inert(first); + before = cancellations; + mounted.rerender(React.createElement(Harness, { editor: replacement })); + assert.ok(cancellations > before, "replacement invalidates pending work"); + inert(second); + replacement.mount(document.createElement("div")); + const third = guarded(replacement); + before = cancellations; + replacement.destroy(); + assert.ok(cancellations > before, "destroy invalidates pending work"); + inert(third); + assert.throws(() => replacement.view.dom, /editor view is not available/); + mounted.unmount(); // cleanup must not read the now-unavailable view + } finally { + editor.destroy(); + replacement.destroy(); + } +}); + +test("unmount fences real pending admission across remount; mounted admission still commits", async () => { + const { React, render, act, Editor, StarterKit, useMentionAdmissionEditor } = + await tools(); + const { useMentionAdmission } = await import("./useMentionAdmission.ts"); + const editor = new Editor({ extensions: [StarterKit] }); + const scope = {}; + let admission; + let commits = 0; + function Harness() { + admission = useMentionAdmission(scope); + useMentionAdmissionEditor(editor, admission.cancel); + return null; + } + const mounted = render(React.createElement(Harness)); + let release; + const prepared = new Promise((resolve) => { + release = resolve; + }); + try { + await act(async () => + admission.begin({ + key: {}, + valid: () => true, + prepare: () => prepared, + commit: () => { + commits++; + }, + }), + ); + assert.equal(admission.status, "Checking access…"); + await act(async () => { + editor.unmount(); + editor.mount(document.createElement("div")); + release(); + await prepared; + }); + assert.equal(commits, 0); + assert.equal(admission.status, ""); + await act(async () => + admission.begin({ + key: {}, + valid: () => true, + prepare: async () => {}, + commit: () => { + commits++; + }, + }), + ); + assert.equal(commits, 1, "new mounted operation is not disabled"); + } finally { + mounted.unmount(); + editor.destroy(); + } +}); diff --git a/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts new file mode 100644 index 00000000000..5b817554c84 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAdmissionEditor.ts @@ -0,0 +1,52 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; + +/** Native edits and selection transactions abandon authority, even after undo/return. */ +export function useMentionAdmissionEditor( + editor: Editor | null, + cancel: () => void, +) { + React.useEffect(() => { + if (!editor) return; + const transaction = ({ + transaction, + }: { + transaction: { docChanged: boolean; selectionSet: boolean }; + }) => { + if (transaction.docChanged || transaction.selectionSet) cancel(); + }; + let dom: HTMLElement | null = null; + const keydown = (event: KeyboardEvent) => { + if (!["Enter", "Tab", " "].includes(event.key)) cancel(); + }; + const detach = () => { + cancel(); + editor.off("transaction", transaction); + dom?.removeEventListener("keydown", keydown); + dom?.removeEventListener("beforeinput", cancel); + dom?.removeEventListener("pointerdown", cancel); + dom = null; + }; + const attach = () => { + if (dom) detach(); + // TipTap's public isDestroyed is also true when there is no view. + // isInitialized/create are delayed until after mount, so cannot gate this. + if (editor.isDestroyed) return; + dom = editor.view.dom; + editor.on("transaction", transaction); + dom.addEventListener("keydown", keydown); + dom.addEventListener("beforeinput", cancel); + dom.addEventListener("pointerdown", cancel); + }; + editor.on("mount", attach); + editor.on("unmount", detach); + editor.on("destroy", detach); + attach(); + return () => { + editor.off("mount", attach); + editor.off("unmount", detach); + editor.off("destroy", detach); + detach(); + }; + }, [editor, cancel]); +} diff --git a/desktop/src/features/messages/lib/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..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( @@ -513,7 +513,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; @@ -563,6 +562,7 @@ function MessageComposerImpl({ onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { + mentions.cancelMentionAdmission(); const trimmed = syncComposerContentFromEditor().trim(); // Edit mode if (editTargetRef.current && onEditSaveRef.current) { @@ -682,6 +682,7 @@ function MessageComposerImpl({ media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, + mentions.cancelMentionAdmission, mentions.clearMentions, richText.clearContent, richText.setContent, @@ -906,7 +907,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/composer-autofocus.spec.ts b/desktop/tests/e2e/composer-autofocus.spec.ts new file mode 100644 index 00000000000..9542544f5af --- /dev/null +++ b/desktop/tests/e2e/composer-autofocus.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +declare global { + interface Window { + __AUTOFOCUS_ORDER__: { + held: number; + delivered: number; + release: () => void; + }; + } +} + +for (const claimed of [true, false]) { + test(`late automatic focus ${claimed ? "respects an open native menu" : "focuses an unclaimed composer"}`, async ({ + page, + }) => { + await page.addInitScript(() => { + const nativeRAF = window.requestAnimationFrame.bind(window); + const held: FrameRequestCallback[] = []; + const state = { + held: 0, + delivered: 0, + release: () => { + for (const callback of held.splice(0)) + nativeRAF((time) => { + callback(time); + state.delivered++; + }); + }, + }; + window.__AUTOFOCUS_ORDER__ = state; + window.requestAnimationFrame = (callback) => { + // Source-bound to scheduleComposerAutofocus's commit (selection + + // scroll). Hold the genuine callback only when the browser delivers it; + // other frames, including Tiptap explicit focus, are forwarded unchanged. + const source = callback.toString(); + const automatic = + source.includes(".state.tr.setSelection(") && + source.includes(".commands.scrollIntoView()"); + return nativeRAF((time) => { + if (automatic) { + held.push(callback); + state.held++; + } else callback(time); + }); + }; + }); + await installMockBridge(page, { + windowLabel: "huddle-11111111-1111-4111-8111-111111111111", + ttsSettings: { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:vera"], + }, + huddle: { + parentChannelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + ephemeralChannelId: "11111111-1111-4111-8111-111111111111", + members: [ + { pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }, + { pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" }, + ], + ttsEnabled: true, + }, + }); + await page.goto("/"); + await expect + .poll(() => page.evaluate(() => window.__AUTOFOCUS_ORDER__.held)) + .toBeGreaterThan(0); + const editor = page.getByTestId("message-input"); + await expect(editor).not.toBeFocused(); + const trigger = page.getByRole("button", { + name: "Voice settings for alice", + }); + const menu = page.locator( + '[data-testid="huddle-agent-voice-menu-content"][data-state="open"]', + ); + if (claimed) { + await trigger.click(); + await expect(menu).toBeVisible(); + await expect(menu.getByTestId("huddle-agent-tts-toggle")).toBeFocused(); + } + await page.evaluate(() => window.__AUTOFOCUS_ORDER__.release()); + await expect + .poll(() => page.evaluate(() => window.__AUTOFOCUS_ORDER__.delivered)) + .toBeGreaterThan(0); + if (claimed) { + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await expect(menu).toBeVisible(); + await expect(editor).not.toBeFocused(); + await menu.getByTestId("huddle-agent-tts-toggle").click(); + await expect( + menu.getByTestId("huddle-agent-tts-toggle"), + ).not.toBeChecked(); + await page.keyboard.press("Escape"); + await editor.click(); + await expect(editor).toBeFocused(); + await editor.fill("explicit typing still works"); + await expect(editor).toHaveText("explicit typing still works"); + } else { + await expect(editor).toBeFocused(); + } + }); +} diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 25d3cbaf9ae..fc188b48edd 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -307,7 +307,26 @@ for (const format of [ await input.pressSequentially("before"); await input.press("Shift+Enter"); await applyCaretFormat(page, format.label); - await input.pressSequentially("inside"); + // Toolbar focus synchronizes the new caret on a later animation frame. + // Locator typing would focus the DOM itself, reviving the old caret. + await expect(input).toBeFocused(); + await expect + .poll(() => + input.evaluate((element, selector) => { + const block = element.querySelector(`:scope > ${selector}`); + const selection = window.getSelection(); + return ( + document.activeElement === element && + !!selection?.isCollapsed && + !!selection.anchorNode && + !!selection.focusNode && + !!block?.contains(selection.anchorNode) && + block.contains(selection.focusNode) + ); + }, format.selector), + ) + .toBe(true); + await page.keyboard.type("inside"); await expect(input.locator(":scope > p").first()).toHaveText("before"); await expect(input.locator(`:scope > ${format.selector}`)).toHaveText( diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 40fbe78a43e..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, @@ -1814,7 +1877,9 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`; await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toContainText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull(); @@ -2172,6 +2237,491 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("cached-visible revoked relay agent selection is denied without a directory refetch", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + + // Backend-only policy revocation: the targeted revalidation command omits + // quinn while nothing refreshes or invalidates the installed chooser cache. + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + // The cached directory keeps serving the same eligible row. + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + await quinnRow.click(); + await expect(page.getByTestId("composer-address-lock-status")).toHaveText( + /Access changed\. Selection was not inserted\./, + ); + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + + const deniedCommands = await captureMentionCommandBoundary(page, "final"); + // The denial came from the targeted prepare-phase revalidation seam… + expect(commandCount(deniedCommands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 1, + ); + // …not from a directory refresh: the cached row was never refetched. + expect(commandCount(deniedCommands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); + // Nothing was inserted, invited, started, or published. + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(deniedCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + + // Control: the identical cached row and click admit once the backend policy + // re-allows the agent, still with no directory refetch. + await page.evaluate(() => { + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = []; + }); + await quinnRow.click(); + await expect(input.locator(".mention-chip")).toHaveCount(1); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + const controlCommands = await readCommandLog(page); + expect(commandCount(controlCommands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(controlCommands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); +}); + +// Deferred IPC seam, the remote-owned-mentions.spec.ts holdInviteCommand +// pattern: hold the exact next targeted revalidation so a real click +// admission parks at the authority boundary instead of racing the browser. +type MentionGateWindow = Window & { + __TAURI_INTERNALS__: { + invoke: (command: string, payload?: unknown) => Promise; + }; + mentionGateEntered?: boolean; + releaseMentionGate?: () => void; +}; +async function holdMentionGateCommand( + page: import("@playwright/test").Page, + command: string, +) { + await page.evaluate( + ({ heldCommand }) => { + const state = window as unknown as MentionGateWindow; + const invoke = state.__TAURI_INTERNALS__.invoke; + const gate = new Promise((resolve) => { + state.releaseMentionGate = resolve; + }); + state.__TAURI_INTERNALS__.invoke = async (command, payload) => { + if (command !== heldCommand) return invoke(command, payload); + state.__TAURI_INTERNALS__.invoke = invoke; + state.mentionGateEntered = true; + await gate; + return invoke(command, payload); + }; + }, + { heldCommand: command }, + ); +} +async function waitForMentionGate(page: import("@playwright/test").Page) { + await expect + .poll(() => + page.evaluate( + () => (window as unknown as MentionGateWindow).mentionGateEntered, + ), + ) + .toBe(true); +} +async function releaseMentionGate(page: import("@playwright/test").Page) { + await page.evaluate(() => { + (window as unknown as MentionGateWindow).releaseMentionGate?.(); + }); +} + +test("navigating to mention Options during a held selection inserts nothing late", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + // Hold the fresh targeted revalidation, then start the selection normally. + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await quinnRow.click(); + await waitForMentionGate(page); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Checking access", + ); + + // Native Shift+Tab from the editor the click never defocused: the app's + // real handler must hand focus to the overlay's Options trigger. + await expect(input).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect( + page.getByTestId("message-composer").getByTestId("mention-options-trigger"), + ).toBeFocused(); + + // Release the held authority response and settle the downstream DOM. + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.waitForTimeout(300); + + // A selection navigated away from the editor must not insert late: the + // draft keeps its raw text, no chip or addressed-agent side effect appears, + // and nothing is invited, started, or published. + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect( + page.getByTestId("composer-address-lock-status"), + ).not.toContainText("Automatically mentioning"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); +}); + +test("navigating away from the mention row pin during its held revalidation pins nothing late", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const composer = page.getByTestId("message-composer"); + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + // Keyboard route into the overlay, no pointer and no test-side focus: the + // app's editor handler hands focus to the Options trigger, then a native + // Tab reaches the row's pin control (row buttons are pointer-guarded + // non-tab stops). + const optionsTrigger = composer.getByTestId("mention-options-trigger"); + const quinnPinToggle = composer.getByTestId( + `mention-always-address-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ); + await expect(input).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(optionsTrigger).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(quinnPinToggle).toBeFocused(); + // The focused control is the pin by role, name and pressed state, and the + // editor is not focused: an Enter here is a pin activation, not a + // selection duplicate. + await expect( + composer.getByRole("button", { + name: "Automatically mention quinn", + exact: true, + }), + ).toBeFocused(); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "false"); + await expect(input).not.toBeFocused(); + + // Start the PIN with a native Enter on the focused pin control and hold + // the pin's fresh targeted revalidation at the deferred-IPC seam. + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await page.keyboard.press("Enter"); + await waitForMentionGate(page); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Checking access", + ); + + // Native Shift+Tab from the pin — the editor's key handler sees no overlay + // events — must really move focus off the pin control. + await page.keyboard.press("Shift+Tab"); + await expect(optionsTrigger).toBeFocused(); + await expect(quinnPinToggle).not.toBeFocused(); + + // Release the held authority response and settle the downstream DOM. + const draftAtDeparture = await input.evaluate( + (element) => element.textContent, + ); + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.waitForTimeout(300); + + // A pin navigated away from must not apply late: the draft keeps its raw + // query text, no implicit prefix or highlight appears, no chip, no + // address-lock audience, no announcement, the pin stays unpressed, and + // nothing is invited, started, or published. + await expect + .poll(() => input.evaluate((element) => element.textContent)) + .toBe(draftAtDeparture); + await expect(input).toHaveText("@quinn"); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toHaveCount(0); + await expect( + page.getByTestId("composer-address-lock-status"), + ).not.toContainText("Automatically mentioning"); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "false"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + + // Positive control: the same native pin activation without navigating + // away must pin through the same real gate, proving the exercised control + // is a working pin and the quiet settlement above was not an inert route. + await page.keyboard.press("Tab"); + await expect(quinnPinToggle).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 2); + await expect(quinnPinToggle).toHaveAttribute("aria-pressed", "true"); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toBeVisible(); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Automatically mentioning quinn", + ); + await expect(input).toHaveText("@quinn "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("quinn"); +}); + +for (const change of ["draft", "selection range"] as const) { + test(`editing and restoring the ${change} during a held mention selection inserts nothing late`, async ({ + page, + }) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForInitialPresenceSigning(page); + + const composer = page.getByTestId("message-composer"); + const input = page.getByTestId("message-input"); + // Read-only DOM offsets: no editor state or browser selection is changed. + const readCaret = () => + input.evaluate((element) => { + const selection = window.getSelection(); + if ( + !selection?.anchorNode || + !selection.focusNode || + !element.contains(selection.anchorNode) || + !element.contains(selection.focusNode) + ) { + return null; + } + const offset = (node: Node, position: number) => { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, position); + return range.toString().length; + }; + return { + anchor: offset(selection.anchorNode, selection.anchorOffset), + focus: offset(selection.focusNode, selection.focusOffset), + collapsed: selection.isCollapsed, + }; + }); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + await expect(input).toBeFocused(); + const originalCaret = await readCaret(); + expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true }); + const baselineCommands = await captureMentionCommandBoundary( + page, + "baseline", + ); + + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await quinnRow.click(); + await waitForMentionGate(page); + const status = page.getByTestId("composer-address-lock-status"); + await expect(status).toContainText("Checking access"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); + + if (change === "draft") { + // Behavior coverage: text edits also invalidate the query revision. + await page.keyboard.press("Backspace"); + await expect(input).toHaveText("@quin"); + await expect(input).toBeFocused(); + await page.keyboard.type("n"); + } else { + // Query snapshots use the selection ANCHOR, not its moving head. + // Extend and retract a real native range without changing text/anchor: + // query revision cannot mask removal of editor cancellation listeners. + await page.keyboard.press("Shift+ArrowLeft"); + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual({ + anchor: 6, + focus: 5, + collapsed: false, + }); + await page.keyboard.press("Shift+ArrowRight"); + } + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); + + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + // Same downstream settlement window as the adjacent held-gate probes. + await page.waitForTimeout(300); + + await expect(input).toBeFocused(); + await expect(input).toHaveText("@quinn"); + await expect.poll(readCaret).toEqual(originalCaret); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toHaveCount(0); + await expect(status).not.toContainText("Checking access"); + await expect(status).not.toContainText("Automatically mentioning"); + const settledCommands = await captureMentionCommandBoundary(page, "final"); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect( + commandCount(settledCommands, command), + `${command} must equal baseline`, + ).toBe(commandCount(baselineCommands, command)); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + }); +} + test("selected relay agents are invited as bots before sending", async ({ page, }) => { @@ -2270,7 +2820,9 @@ test("selected relay agents revoked after the invite prompt cause no side effect await inviteButton.click(); await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toHaveText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); @@ -2324,7 +2876,9 @@ test("selected relay agents revoked during send emit no p tag", async ({ }); await expect( - page.getByText(/Could not authorize a mentioned agent/), + page.getByText( + /Could not check access for a mentioned agent\. Retry or remove the mention\./, + ), ).toBeVisible(); await expect(input).toHaveText("@quinn hello"); expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index aa34b9f1c12..b9d5e9efb8d 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", @@ -462,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); @@ -523,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) => @@ -536,12 +541,67 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { throw new Error("control B draft missing"); return drafts[`thread:${root}`] ?? null; }, roots); - expect(await sourceRecord()).toBeNull(); - // Expando proves the actual editor DOM host survived A -> B. - await input.evaluate((el) => - el.setAttribute("data-lifecycle-host", "retained"), - ); + await input.fill("new authored text"); + if (savedFirst) { + // Additional positive control: real scope cleanup saves live nonempty + // text, exact refs and selection. Keep the rapid unsaved case above. + await navigate(roots[1]); + expect(await sourceRecord()).toMatchObject({ + content: "new authored text", + mentionRefs: [], + selectionStart: 17, + selectionEnd: 17, + }); + await navigate(roots[0]); + await expect(input).toHaveText("new authored text"); + } + // Native fill can return before DOMObserver dispatches the PM edit. + // Arm before deletion; observe the first update FROM the authored document, + // not eventual emptiness/storage quiescence. Production onUpdate was + // registered first and synchronously owns empty-authority persistence. + const deletion = await input.evaluateHandle((el, root) => { + el.setAttribute("data-lifecycle-host", "retained"); + const editor = ( + el as HTMLElement & { editor: import("@tiptap/core").Editor } + ).editor; + return { + completed: new Promise((resolve) => { + const onUpdate = ({ + transaction, + }: import("@tiptap/core").EditorEvents["update"]) => { + if (transaction.before.textContent !== "new authored text") return; + editor.off("update", onUpdate); + const key = Object.keys(localStorage).find((key) => + key.startsWith("buzz-drafts.v2"), + ); + resolve({ + dom: el.textContent, + doc: editor.getJSON(), + from: editor.state.selection.from, + to: editor.state.selection.to, + source: key + ? (JSON.parse(localStorage.getItem(key) ?? "{}")[ + `thread:${root}` + ] ?? null) + : "draft storage scope missing", + }); + }; + editor.on("update", onUpdate); + }), + }; + }, roots[0]); + await input.fill(""); + const deleted = await deletion.evaluate(({ completed }) => completed); + // No assertion/poll between action completion and outgoing-key cleanup. await navigate(roots[1]); + expect(deleted).toEqual({ + dom: "", + doc: { type: "doc", content: [{ type: "paragraph" }] }, + from: 1, + to: 1, + source: null, + }); + await deletion.dispose(); await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); await expect(input).toHaveText(incoming); await expect(page.getByRole("alertdialog")).toHaveCount(0); diff --git a/docs/mention-editor.md b/docs/mention-editor.md index 980fa72647a..2fa0aafc550 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -95,3 +95,50 @@ have not yet installed, they still wait for the required discovery to settle. Change the completion text or explicitly reopen to discover a new set of choices. Live access is checked again at selection, including for rows whose display snapshot originally permitted mentioning. +Failed or pending Retry does not authorize cached choices. + +## Fresh selection and pin admission + +Chat pointer, Enter, Tab and exact-Space choices, explicit automatic-address +adds (including the closed-picker default-agent shortcut), and standalone forum +choices share one cancellable prepare/commit operation. Discovery and cached +availability are preflight only: the exact selected agent keys and destination +are freshly revalidated in prepare mode. A team is admitted as its captured +recipient set, never partially or by resolving its name again. Human-only and +unresolved-persona choices retain their existing recipient semantics. + +No label reservation, selected-agent intent, successful-selection history, +editor replacement, automatic audience or generated-prefix provenance is added +while checking. A single fenced synchronous commit owns those effects. The raw +insertion implementation is private; restoration/registration of existing draft +intent is not a new user selection. The displayed-row WeakMap associates live +availability overlays with installed row identities; it is not a retained-list +or moving-ranking authority. The closed-picker shortcut holds at most one exact +issued choice, bound to the editor/query revision. + +“Checking access…” is visible and announced in chat and forum. Access denial +leaves the draft unchanged and says access changed; failed lookup or the +15-second timeout says access could not be checked. Selecting again retries +with fresh evidence. A duplicate action for the same pending choice is consumed, +not queued for publication. A different choice supersedes it. Typing, native +input, selection transactions, dismissal, navigation, submission and unmount +abandon pending work; returning to the same text, caret or scope does not revive +it. Native editing is never disabled by selection admission. Unexpected commit +errors are reported as a failure to finish selection (inspect the draft before +retry), not mislabeled as an authority denial. + +Unpin/removal remains available without permission and cancels pending adds. +Existing automatic-address restoration remains separate from new pin admission. +Native Enter outside the chooser and non-exact/ambiguous Space retain their +normal handling. Only successful insertion settles the mention caret. + +Selection is not a send permit: publication independently revalidates authority +for the actual destination, including revocation after successful selection. +Offline fresh admission therefore fails closed without clearing the draft. +Loss of live choice eligibility abandons a pending admission immediately; a +later Retry restoring that same row cannot revive the older operation. +Focused coverage in `freshMentionChatJourney.test.mjs` mounts the production +chat picker with Tiptap and the actual standalone `ForumComposer`; its DOM is +JSDOM with fixture IPC, **not** browser or live-relay evidence. Browser, +relay-backed revocation, full-package gates and independent review remain +separate release gates.