From 5d81dbe84617db07858a1964c6fb7e33a33a233a Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 31 Aug 2026 11:07:00 -0700 Subject: [PATCH] fix(desktop): make composer agent recipients predictable Show resolved one-shot and automatic agent recipients consistently. Keep ordinary mention selection preference-respecting; reserve enabling automation for the explicit pin action. Remove all matching mentions on X and expose hidden recipients through a keyboard-accessible overflow list. Guard deferred restoration against removed recipients and stale conversation scope. Cover channel/thread, typed/toolbar selection, automation on/off, removal, re-add, upload settlement, scope changes, and keyboard overflow behavior. Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz> Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../ui/ComposerAddressControls.test.mjs | 16 +- .../messages/ui/ComposerAddressControls.tsx | 97 ++- .../features/messages/ui/MessageComposer.tsx | 13 +- .../useAddressedAgentMentionRestore.test.mjs | 389 +++++++++++ .../ui/useAddressedAgentMentionRestore.ts | 92 ++- .../ui/useAgentAddressLockPicker.test.mjs | 92 ++- .../messages/ui/useAgentAddressLockPicker.ts | 102 +-- .../ui/useComposerAgentRecipients.test.mjs | 116 ++++ .../messages/ui/useComposerAgentRecipients.ts | 78 +++ .../e2e/persistent-agent-audience.spec.ts | 628 +++++++++--------- .../tests/helpers/persistentAgentAudience.ts | 278 ++++++++ 11 files changed, 1433 insertions(+), 468 deletions(-) create mode 100644 desktop/src/features/messages/ui/useAddressedAgentMentionRestore.test.mjs create mode 100644 desktop/src/features/messages/ui/useComposerAgentRecipients.test.mjs create mode 100644 desktop/src/features/messages/ui/useComposerAgentRecipients.ts create mode 100644 desktop/tests/helpers/persistentAgentAudience.ts diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 5dfc851bfd..c70a74f5d3 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -41,7 +41,7 @@ const thirdAgent = { pubkey: "third-agent-pubkey", }; -test("mention control expands with automatically mentioned agents", async () => { +test("mention control expands with current message recipients", async () => { const React = await import("react"); const { fireEvent, render } = await import("@testing-library/react"); const { TooltipProvider } = await import("@/shared/ui/tooltip"); @@ -72,7 +72,7 @@ test("mention control expands with automatically mentioned agents", async () => const avatar = view.getByTestId("composer-address-lock-agent-pubkey"); assert.ok(avatar); const manage = view.getByRole("button", { - name: "Manage automatic agent mentions", + name: "Mention someone", }); assert.match(manage.className, /(?:^|\s)-ml-2(?:\s|$)/); assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/); @@ -82,18 +82,18 @@ test("mention control expands with automatically mentioned agents", async () => /(?:^|\s)pr-1\.5(?:\s|$)/, ); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Mention someone" }).parentElement + ?.className ?? "", /(?:^|\s)bg-primary\/15(?:\s|$)/, ); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Mention someone" }).parentElement + ?.className ?? "", /(?:^|\s)text-primary(?:\s|$)/, ); assert.doesNotMatch( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Mention someone" }).parentElement + ?.className ?? "", /(?:^|\s)bg-accent\/70(?:\s|$)/, ); assert.doesNotMatch( diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index ae39931334..82f182fb01 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -9,13 +9,19 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export type ComposerAddressAgent = { avatarUrl: string | null; displayName: string; pubkey: string; + isAutomatic?: boolean; }; type AddressAnimationProps = { @@ -77,16 +83,53 @@ function AddressedAgentAvatar({ ); } -function RemainingAgentCount({ count }: { count: number }) { - return count > 0 ? ( - - +{count} - - ) : null; +function RemainingAgents({ + agents, + disabled, + onRemove, +}: { + agents: readonly ComposerAddressAgent[]; + disabled: boolean; + onRemove: (pubkey: string) => void; +}) { + if (agents.length === 0) return null; + return ( + + + + + + {agents.map((agent) => ( + + ))} + + + ); } const VISIBLE_AGENT_LIMIT = 3; @@ -150,7 +193,7 @@ export function ComposerMentionButton({ showAgents: boolean; }) { const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : []; - const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0; + const hiddenAgents = showAgents ? agents.slice(VISIBLE_AGENT_LIMIT) : []; const hasAgents = visibleAgents.length > 0; const shouldReduceMotion = useReducedMotion(); const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents); @@ -180,11 +223,7 @@ export function ComposerMentionButton({ - - {hasAgents - ? "Manage automatic agent mentions" - : "Mention someone"} - + Mention someone - + - Don't automatically mention {agent.displayName} in this - conversation + {agent.isAutomatic + ? `${agent.displayName} will be mentioned in this and future messages.` + : `${agent.displayName} will be mentioned in this message only.`} + {" Click to remove."} ))} - + ) : null} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0cc8650795..0c2b1251f9 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -56,6 +56,7 @@ import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker"; +import { useComposerAgentRecipients } from "./useComposerAgentRecipients"; import { useAddressMentionPulse } from "./useAddressMentionPulse"; import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut"; import { useComposerMentionPicker } from "./useComposerMentionPicker"; @@ -129,7 +130,7 @@ function MessageComposerImpl({ const effectiveDraftKey = draftKey ?? channelId; const ownerPubkey = identityQuery.data?.pubkey ?? null; const audienceScope = - audienceContext && channelId && ownerPubkey + audienceContext && channelId && ownerPubkey && editTarget == null ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, @@ -333,7 +334,7 @@ function MessageComposerImpl({ onTurnOn: () => setKeepMentionedAgentsPinned(true), }); const addressedMentionRestore = useAddressedAgentMentionRestore({ - audiencePubkeys: persistentAudience.pubkeys, + audienceScope, channelId, enabled: keepMentionedAgentsPinned, }); @@ -464,6 +465,12 @@ function MessageComposerImpl({ profiles, richText, }); + const addressedAgents = useComposerAgentRecipients({ + audiencePubkeys: persistentAudience.pubkeys, + mentions, + profiles, + richText, + }); addressedMentionRestore.restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; React.useLayoutEffect(() => { @@ -936,7 +943,7 @@ function MessageComposerImpl({ ", { + url: "http://localhost", +}); +let nextFrame = 0; +let frames = new Map(); + +function flushFrames() { + const queuedFrames = frames; + frames = new Map(); + for (const callback of queuedFrames.values()) callback(0); +} + +before(() => { + Object.assign(globalThis, { + cancelAnimationFrame: (frame) => frames.delete(frame), + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame: (callback) => { + nextFrame += 1; + frames.set(nextFrame, callback); + return nextFrame; + }, + window: dom.window, + }); +}); + +beforeEach(async () => { + const { resetPersistentAgentAudienceStore } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + resetPersistentAgentAudienceStore(); + frames = new Map(); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + frames = new Map(); +}); + +after(() => dom.window.close()); + +async function renderRestoreHook(initialProps) { + const { renderHook } = await import("@testing-library/react"); + const { useAddressedAgentMentionRestore } = await import( + "./useAddressedAgentMentionRestore.ts" + ); + return renderHook((props) => useAddressedAgentMentionRestore(props), { + initialProps, + }); +} + +test("does not restore the cleared composer after a scope transition", async () => { + const { act } = await import("@testing-library/react"); + const rootScope = "owner:channel:channel"; + const threadScope = "owner:channel:thread"; + const restored = []; + const { result, rerender } = await renderRestoreHook({ + audienceScope: rootScope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return "@Agent "; + }; + const capturedClear = result.current.onAddressedAgentsComposerCleared; + + act(() => { + rerender({ + audienceScope: threadScope, + channelId: "channel", + enabled: true, + }); + }); + const restoredText = capturedClear([AGENT_A]); + + assert.equal(restoredText, ""); + assert.deepEqual(restored, []); +}); + +test("does not restore the cleared composer after leaving and returning to its scope", async () => { + const { act } = await import("@testing-library/react"); + const rootScope = "owner:channel:channel"; + const threadScope = "owner:channel:thread"; + const restored = []; + const { result, rerender } = await renderRestoreHook({ + audienceScope: rootScope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return "@Agent "; + }; + const capturedClear = result.current.onAddressedAgentsComposerCleared; + + act(() => { + rerender({ + audienceScope: threadScope, + channelId: "channel", + enabled: true, + }); + }); + act(() => { + rerender({ + audienceScope: rootScope, + channelId: "channel", + enabled: true, + }); + }); + const restoredText = capturedClear([AGENT_A]); + + assert.equal(restoredText, ""); + assert.deepEqual(restored, []); +}); + +test("does not restore the cleared composer after unmount", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const restored = []; + const { result, unmount } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return "@Agent "; + }; + const capturedClear = result.current.onAddressedAgentsComposerCleared; + + act(() => unmount()); + const restoredText = capturedClear([AGENT_A]); + + assert.equal(restoredText, ""); + assert.deepEqual(restored, []); +}); + +test("restores the cleared composer when its audience is current", async () => { + const scope = "owner:channel:channel"; + const { setPersistentAgentAudience } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return "@Agent A "; + }; + + const restoredText = result.current.onAddressedAgentsComposerCleared([ + AGENT_A, + ]); + + assert.equal(restoredText, "@Agent A "); + assert.deepEqual(restored, [[[AGENT_A]]]); +}); + +test("clearing after same-tick removal restores only remaining agents", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } = + await import("@/features/messages/lib/persistentAgentAudience.ts"); + setPersistentAgentAudience(scope, [AGENT_A, AGENT_B]); + const restored = []; + const { result } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return "@Agent B "; + }; + const capturedClear = result.current.onAddressedAgentsComposerCleared; + + let restoredText; + act(() => { + excludePersistentAgentAudienceMember(scope, AGENT_A); + restoredText = capturedClear([AGENT_A, AGENT_B]); + }); + + assert.equal(restoredText, "@Agent B "); + assert.deepEqual(restored, [[[AGENT_B]]]); +}); + +test("restores a successful send when its audience is still current", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { setPersistentAgentAudience } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + + act(() => { + result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]); + flushFrames(); + }); + + assert.deepEqual(restored, [[[AGENT_A]]]); +}); + +test("does not restore after a same-tick audience removal", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } = + await import("@/features/messages/lib/persistentAgentAudience.ts"); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + + act(() => { + result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]); + excludePersistentAgentAudienceMember(scope, AGENT_A); + flushFrames(); + }); + + assert.deepEqual(restored, []); +}); + +test("does not restore when success is invoked after removal", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } = + await import("@/features/messages/lib/persistentAgentAudience.ts"); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result, rerender } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + const capturedSuccess = result.current.onAddressedAgentsSendSucceeded; + + act(() => { + excludePersistentAgentAudienceMember(scope, AGENT_A); + rerender({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + capturedSuccess([AGENT_A], [AGENT_A]); + flushFrames(); + }); + + assert.deepEqual(restored, []); +}); + +test("does not restore after automatic mentions are turned off", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { setPersistentAgentAudience } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result, rerender } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + + act(() => { + result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]); + }); + act(() => { + rerender({ + audienceScope: scope, + channelId: "channel", + enabled: false, + }); + }); + act(() => { + flushFrames(); + }); + + assert.deepEqual(restored, []); +}); + +test("does not restore after the composer unmounts", async () => { + const { act } = await import("@testing-library/react"); + const scope = "owner:channel:channel"; + const { setPersistentAgentAudience } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + setPersistentAgentAudience(scope, [AGENT_A]); + const restored = []; + const { result, unmount } = await renderRestoreHook({ + audienceScope: scope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + + const capturedSuccess = result.current.onAddressedAgentsSendSucceeded; + act(() => { + capturedSuccess([AGENT_A], [AGENT_A]); + unmount(); + capturedSuccess([AGENT_A], [AGENT_A]); + flushFrames(); + }); + + assert.deepEqual(restored, []); +}); + +test("does not restore after leaving and returning to the same scope", async () => { + const { act } = await import("@testing-library/react"); + const rootScope = "owner:channel:channel"; + const threadScope = "owner:channel:thread"; + const { setPersistentAgentAudience } = await import( + "@/features/messages/lib/persistentAgentAudience.ts" + ); + setPersistentAgentAudience(rootScope, [AGENT_A]); + setPersistentAgentAudience(threadScope, [AGENT_B]); + const restored = []; + const { result, rerender } = await renderRestoreHook({ + audienceScope: rootScope, + channelId: "channel", + enabled: true, + }); + result.current.restoreAddressedAgentMentionsRef.current = (...args) => { + restored.push(args); + return ""; + }; + + act(() => { + result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]); + }); + act(() => { + rerender({ + audienceScope: threadScope, + channelId: "channel", + enabled: true, + }); + }); + act(() => { + rerender({ + audienceScope: rootScope, + channelId: "channel", + enabled: true, + }); + flushFrames(); + }); + + assert.deepEqual(restored, []); +}); diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts index 9bc88ea6b4..46c9b2d191 100644 --- a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts @@ -1,61 +1,115 @@ import * as React from "react"; +import { + getPersistentAgentAudienceRevision, + getPersistentAgentAudienceSnapshot, +} from "@/features/messages/lib/persistentAgentAudience"; + type RestoreAddressedAgentMentions = ( pubkeys?: readonly string[], allowedUnpinnedPubkeys?: readonly string[], ) => string; export function useAddressedAgentMentionRestore({ - audiencePubkeys, + audienceScope, channelId, enabled, }: { - audiencePubkeys: readonly string[]; + audienceScope: string | null; channelId: string | null; enabled: boolean; }) { const restoreAddressedAgentMentionsRef = React.useRef(() => ""); const restoreFrameRef = React.useRef(null); + const audienceScopeRef = React.useRef(audienceScope); + const audienceScopeGenerationRef = React.useRef(0); + if (audienceScopeRef.current !== audienceScope) { + audienceScopeRef.current = audienceScope; + audienceScopeGenerationRef.current += 1; + } + const renderAudienceScopeGeneration = audienceScopeGenerationRef.current; const channelIdRef = React.useRef(channelId); channelIdRef.current = channelId; + const enabledRef = React.useRef(enabled); + enabledRef.current = enabled; + const isMountedRef = React.useRef(false); - React.useEffect( - () => () => { + React.useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; if (restoreFrameRef.current !== null) { cancelAnimationFrame(restoreFrameRef.current); } - }, - [], - ); + }; + }, []); const onAddressedAgentsComposerCleared = React.useCallback( - (pubkeys: readonly string[]) => - restoreAddressedAgentMentionsRef.current(pubkeys), - [], + (pubkeys: readonly string[]) => { + const clearedAudienceScope = audienceScope; + const clearedAudienceScopeGeneration = renderAudienceScopeGeneration; + if ( + !isMountedRef.current || + !clearedAudienceScope || + audienceScopeRef.current !== clearedAudienceScope || + audienceScopeGenerationRef.current !== clearedAudienceScopeGeneration + ) { + return ""; + } + const currentAudience = new Set( + getPersistentAgentAudienceSnapshot().audiences[clearedAudienceScope] ?? + [], + ); + return restoreAddressedAgentMentionsRef.current( + pubkeys.filter((pubkey) => currentAudience.has(pubkey)), + ); + }, + [audienceScope, renderAudienceScopeGeneration], ); const onAddressedAgentsSendSucceeded = React.useCallback( (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { - const currentAudience = new Set(audiencePubkeys); - const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) => - currentAudience.has(pubkey), - ); - if (!enabled || confirmedPinnedPubkeys.length === 0) return; + const sentAudienceScope = audienceScope; + if ( + !isMountedRef.current || + !enabled || + !sentAudienceScope || + newlyPinnedPubkeys.length === 0 + ) + return; const sentChannelId = channelId; + const sentAudienceScopeGeneration = renderAudienceScopeGeneration; + const sentAudienceRevision = + getPersistentAgentAudienceRevision(sentAudienceScope); if (restoreFrameRef.current !== null) { cancelAnimationFrame(restoreFrameRef.current); } restoreFrameRef.current = requestAnimationFrame(() => { restoreFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; + if ( + !enabledRef.current || + audienceScopeRef.current !== sentAudienceScope || + audienceScopeGenerationRef.current !== sentAudienceScopeGeneration || + channelIdRef.current !== sentChannelId || + getPersistentAgentAudienceRevision(sentAudienceScope) !== + sentAudienceRevision + ) { + return; + } + + const currentAudience = new Set( + getPersistentAgentAudienceSnapshot().audiences[sentAudienceScope] ?? + [], + ); + if (!newlyPinnedPubkeys.some((pubkey) => currentAudience.has(pubkey))) + return; restoreAddressedAgentMentionsRef.current( - pubkeys, - confirmedPinnedPubkeys, + pubkeys.filter((pubkey) => currentAudience.has(pubkey)), ); }); }, - [audiencePubkeys, channelId, enabled], + [audienceScope, channelId, enabled, renderAudienceScopeGeneration], ); return { diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 8ed155234f..1e92bbe2b3 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -617,12 +617,14 @@ test("deleting human mentions is ignored while deleting a restored automatic age assert.deepEqual(removedPubkeys, []); }); -test("selecting an agent from the explicit picker auto-addresses it", async () => { +test("selecting an agent from the toolbar respects the same auto-pin callback as a typed query", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); const appliedEdits = []; + const autoPinnedSuggestions = []; + const explicitlyAddressed = []; const addedPubkeys = []; const pulsedPubkeys = []; const mentions = { @@ -651,6 +653,10 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = audience, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), + onAddressAgentMention: (suggestion) => + explicitlyAddressed.push(suggestion), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -671,15 +677,23 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = insertText: "@Agent Ada ", }, ]); - assert.deepEqual(addedPubkeys, ["agent-pubkey"]); - assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); - assert.equal( - result.current.announcement, - "Automatically mentioning Agent Ada", - ); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); + assert.deepEqual(explicitlyAddressed, []); + assert.deepEqual(autoPinnedSuggestions, [ + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: true }, + ], + ]); + assert.equal(result.current.announcement, ""); }); -test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => { +test("explicitly re-adding a removed agent follows the current automation preference", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -775,7 +789,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua displayName: "Agent Ada", isAgent: true, }, - { reinstateExcluded: false }, + { reinstateExcluded: true }, ], [ { @@ -783,7 +797,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua displayName: "Agent Ada", isAgent: true, }, - { reinstateExcluded: false }, + { reinstateExcluded: true }, ], ]); assert.deepEqual(pulsedPubkeys, []); @@ -865,3 +879,61 @@ test("an addressed agent keeps its resolved name while mention state clears duri assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); }); + +for (const text of [ + "@Agent Ada @Agent Bea hello", + "hello @Agent Bea and @Agent Ada then @Agent Bea", +]) { + test(`avatar removal strips every occurrence, not only a prefix: ${text}`, async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const { getMentionOffsets } = await import("../lib/hasMention.ts"); + let currentText = text; + const excluded = []; + const refs = [ + { displayName: "Agent Ada", pubkey: "agent-a", isAgent: true }, + { displayName: "Agent Bea", pubkey: "agent-b", isAgent: true }, + ]; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: ({ + replaceFromOffset, + replaceToOffset, + insertText, + }) => { + currentText = + currentText.slice(0, replaceFromOffset) + + insertText + + currentText.slice(replaceToOffset); + }, + audience: { + pubkeys: ["agent-a", "agent-b"], + excludePubkey: (key) => excluded.push(key), + }, + audienceScope: "channel", + mentions: { + getMentionDisplayName: (key) => + refs.find((ref) => ref.pubkey === key)?.displayName, + getDraftMentionRefs: (value) => + refs.filter( + (ref) => getMentionOffsets(value, ref.displayName).length > 0, + ), + }, + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ + text: currentText, + cursor: currentText.length, + }), + }, + }), + ); + act(() => result.current.removeAddressedAgent("agent-b")); + assert.deepEqual(excluded, ["agent-b"]); + assert.equal(getMentionOffsets(currentText, "Agent Bea").length, 0); + assert.equal(getMentionOffsets(currentText, "Agent Ada").length, 1); + assert.ok(currentText.includes("hello")); + }); +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 4a9ca5f15c..c0f4ba46a9 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { getMentionOffsets } from "@/features/messages/lib/hasMention"; -import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { @@ -184,24 +183,19 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.add(normalized); const excludePubkey = audience.excludePubkey ?? audience.removePubkey; excludePubkey(normalized); - const displayName = lockedAgents.find( - (agent) => agent.pubkey === normalized, - )?.displayName; - if (displayName) { - const text = richText.getPlainTextAndCursor().text; - const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`; - const strippedText = stripImplicitAgentMentionPrefix( - text, - implicitPrefix, - ); - if (strippedText !== text) { - onImplicitPrefixRemoved?.(normalized); - applyAutocompleteEdit({ - replaceFromOffset: 0, - replaceToOffset: text.length - strippedText.length, - insertText: "", - }); - } + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + const displayName = + mentions.getMentionDisplayName(normalized) ?? + lockedAgentNamesRef.current.get(normalized); + if (displayName) matchingDisplayNames.push(displayName); + onImplicitPrefixRemoved?.(normalized); + visibleAgentMentionPubkeysRef.current.delete(normalized); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); } }, [ @@ -209,30 +203,9 @@ export function useAgentAddressLockPicker({ audience.excludePubkey, audience.removePubkey, audienceScope, - lockedAgents, - onImplicitPrefixRemoved, - richText.getPlainTextAndCursor, - ], - ); - const removeAddressedAgentMentions = React.useCallback( - (pubkey: string) => { - const normalized = normalizePubkey(pubkey); - if (!audienceScope || !normalized) return; - const { text } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === normalized) - .map((ref) => ref.displayName); - for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { - applyAutocompleteEdit(edit); - } - removeAddressedAgent(normalized); - }, - [ - applyAutocompleteEdit, - audienceScope, mentions.getDraftMentionRefs, - removeAddressedAgent, + mentions.getMentionDisplayName, + onImplicitPrefixRemoved, richText.getPlainTextAndCursor, ], ); @@ -242,7 +215,7 @@ export function useAgentAddressLockPicker({ if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgentMentions(pubkey); + removeAddressedAgent(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); @@ -313,7 +286,7 @@ export function useAgentAddressLockPicker({ onAddressAgentMention, onImplicitPrefixInserted, onPulseAddressLock, - removeAddressedAgentMentions, + removeAddressedAgent, richText.getPlainTextAndCursor, trackMentionAddressedAgent, ], @@ -322,47 +295,22 @@ export function useAgentAddressLockPicker({ const selectMentionSuggestion = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (suggestion.isAgent && pubkey && audienceScope) { - const { cursor } = richText.getPlainTextAndCursor(); - const wasUnpinned = - !lockedAgentPubkeys.has(pubkey) && - unpinnedAgentPubkeysRef.current.has(pubkey); - if (mentions.isInlineMentionSelection() || wasUnpinned) { - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion, { - reinstateExcluded: !wasUnpinned, - }); - return; - } - - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - 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; - } - const { cursor } = richText.getPlainTextAndCursor(); applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (suggestion.isAgent && pubkey && audienceScope) { + // Adding a mention has the same meaning from the toolbar or typed @. + // Only the separate pin action may enable automatic mentioning. + unpinnedAgentPubkeysRef.current.delete(pubkey); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion, { reinstateExcluded: true }); + if (lockedAgentPubkeys.has(pubkey)) onPulseAddressLock(pubkey); + } }, [ applyAutocompleteEdit, - audience.addPubkey, audienceScope, lockedAgentPubkeys, - mentions.isInlineMentionSelection, mentions.insertMention, - onAddressAgentMention, onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, diff --git a/desktop/src/features/messages/ui/useComposerAgentRecipients.test.mjs b/desktop/src/features/messages/ui/useComposerAgentRecipients.test.mjs new file mode 100644 index 0000000000..dce75dd9cd --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerAgentRecipients.test.mjs @@ -0,0 +1,116 @@ +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.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + Node: dom.window.Node, + getComputedStyle: dom.window.getComputedStyle, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }), +); +afterEach(async () => (await import("@testing-library/react")).cleanup()); +after(() => dom.window.close()); + +test("recipient preview reacts to editor and audience changes without rerendering on ordinary typing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { Editor } = await import("@tiptap/core"); + const { default: StarterKit } = await import("@tiptap/starter-kit"); + const { useComposerAgentRecipients } = await import( + "./useComposerAgentRecipients.ts" + ); + const { extractMentionPubkeys } = await import( + "../lib/extractMentionPubkeys.ts" + ); + const agentA = "a".repeat(64); + const agentB = "b".repeat(64); + const human = "c".repeat(64); + const candidates = [ + { displayName: "Ada", pubkey: agentA, isMember: true }, + { displayName: "Bea", pubkey: agentB, isMember: true }, + { displayName: "Human", pubkey: human, isMember: true }, + ]; + const editor = new Editor({ + extensions: [StarterKit], + content: "

@Ada hello

", + }); + let serializations = 0; + let renders = 0; + const mentions = { + getDraftMentionRefs: () => [], + extractMentionPubkeys: (text) => + extractMentionPubkeys({ + text, + selectedMentions: new Map(), + memberCandidates: candidates, + }), + isAgentPubkey: (key) => key === agentA || key === agentB, + getMentionDisplayName: (key) => + candidates.find((candidate) => candidate.pubkey === key)?.displayName, + }; + const richText = { + editor, + getMarkdown: () => { + serializations += 1; + return editor.getText(); + }, + }; + const { result, rerender, unmount } = renderHook( + ({ audience }) => { + renders += 1; + return useComposerAgentRecipients({ + audiencePubkeys: audience, + mentions, + richText, + }); + }, + { initialProps: { audience: [] } }, + ); + assert.deepEqual( + result.current.map(({ pubkey, isAutomatic }) => [pubkey, isAutomatic]), + [[agentA, false]], + ); + const beforeTyping = renders; + act(() => { + editor.commands.insertContentAt( + editor.state.doc.content.size - 1, + " world", + ); + }); + assert.equal(renders, beforeTyping); + const beforeSelection = serializations; + act(() => { + editor.commands.setTextSelection(1); + }); + assert.equal(serializations, beforeSelection); + act(() => { + editor.commands.setContent("

@Ada @Bea @Human

"); + }); + assert.deepEqual( + result.current.map(({ pubkey }) => pubkey), + [agentA, agentB], + ); + rerender({ audience: [agentB] }); + assert.equal( + result.current.find(({ pubkey }) => pubkey === agentB).isAutomatic, + true, + ); + act(() => { + editor.commands.setContent("

@Human

"); + }); + assert.deepEqual( + result.current.map(({ pubkey }) => pubkey), + [agentB], + ); + rerender({ audience: [] }); + assert.deepEqual(result.current, []); + unmount(); + editor.destroy(); +}); diff --git a/desktop/src/features/messages/ui/useComposerAgentRecipients.ts b/desktop/src/features/messages/ui/useComposerAgentRecipients.ts new file mode 100644 index 0000000000..957df9d2ba --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerAgentRecipients.ts @@ -0,0 +1,78 @@ +import * as React from "react"; +import { useEditorState } from "@tiptap/react"; + +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import type { ComposerAddressAgent } from "./ComposerAddressControls"; +import { mergeMentionRecipients } from "./useMentionSendFlow.helpers"; + +/** Preview the same resolved identities used at the send boundary. */ +export function useComposerAgentRecipients({ + audiencePubkeys, + mentions, + profiles, + richText, +}: { + audiencePubkeys: readonly string[]; + mentions: UseMentionsResult; + profiles?: UserProfileLookup; + richText: UseRichTextEditorResult; +}): ComposerAddressAgent[] { + const serializedRef = React.useRef<{ + document: unknown; + text: string; + }>({ document: null, text: "" }); + return ( + useEditorState({ + editor: richText.editor, + selector: ({ editor }) => { + const document = editor?.state.doc; + // Do not serialize on selection transactions or rerender the composer + // for ordinary typing. useEditorState compares the recipient rows. + if (serializedRef.current.document !== document) { + serializedRef.current = { + document, + text: document?.textContent.includes("@") + ? richText.getMarkdown() + : "", + }; + } + const text = serializedRef.current.text; + const refs = mentions.getDraftMentionRefs(text); + const automatic = new Set(audiencePubkeys.map(normalizePubkey)); + const selectedAgents = new Set( + refs + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + return mergeMentionRecipients( + mentions.extractMentionPubkeys(text), + audiencePubkeys, + ) + .filter( + (pubkey) => + automatic.has(pubkey) || + selectedAgents.has(pubkey) || + mentions.isAgentPubkey(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + return { + pubkey, + displayName: + refs.find((ref) => normalizePubkey(ref.pubkey) === pubkey) + ?.displayName || + mentions.getMentionDisplayName(pubkey) || + profile?.displayName || + profile?.name || + truncatePubkey(pubkey), + avatarUrl: profile?.avatarUrl ?? null, + isAutomatic: automatic.has(pubkey), + }; + }); + }, + }) ?? [] + ); +} diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 643031454a..39f18e68dc 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,16 +1,29 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; -import { installMockBridge } from "../helpers/bridge"; +import { + AGENT_A, + AGENT_B, + KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY, + RANDOM_CHANNEL_ID, + addMention, + automaticallyMention, + channelComposer, + emitMockMessage, + focusComposerEnd, + installAudienceFixtures, + keepMentionedAgentsPinned, + openGeneral, + openThread, + pressPrimaryShiftM, + readComposerCaret, + readOutgoingMentionPubkeys, + readPersistedDraft, + seedTheme, + threadComposer, +} from "../helpers/persistentAgentAudience"; const SHOTS = "test-results/persistent-agent-audience"; -const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; -const AGENT_A = "a".repeat(64); -const AGENT_B = "b".repeat(64); -const THREAD_ROOT_ID = "mock-general-welcome"; -const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = - "buzz.messages.keepMentionedAgentsPinned"; test.beforeEach(async ({ page }) => { await page.addInitScript((storageKey) => { @@ -18,181 +31,6 @@ test.beforeEach(async ({ page }) => { }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); }); -async function keepMentionedAgentsPinned(page: Page) { - await page.addInitScript((storageKey) => { - window.localStorage.setItem(storageKey, "true"); - }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); -} - -async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") { - await page.addInitScript( - ({ selectedTheme, selectedAccent }) => { - window.localStorage.setItem("buzz-theme", selectedTheme); - window.localStorage.setItem("buzz-accent-color", selectedAccent); - }, - { selectedTheme: theme, selectedAccent: accent }, - ); -} - -async function automaticallyMention( - composer: ReturnType, - displayName: string, -) { - await composer.locator("[data-mention-picker-trigger]").click(); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: `Automatically mention ${displayName}` }) - .click(); - await expect(composer.getByTestId("message-input")).toContainText( - `@${displayName}`, - ); - await composer.locator("[data-mention-picker-trigger]").click(); -} - -async function openGeneral(page: Page) { - await page.goto(`/#/channels/${CHANNEL_ID}`, { - waitUntil: "domcontentloaded", - }); - await expect(page.getByTestId("chat-title")).toHaveText("general"); -} - -async function openThread(page: Page, threadRootId = THREAD_ROOT_ID) { - await page.goto( - `/#/channels/${CHANNEL_ID}?messageId=${threadRootId}&thread=${threadRootId}`, - { waitUntil: "domcontentloaded" }, - ); - await expect(page.getByTestId("message-thread-panel")).toBeVisible(); -} - -function channelComposer(page: Page) { - return page.getByTestId("channel-composer-overlay"); -} - -function threadComposer(page: Page) { - return page.getByTestId("thread-composer-overlay"); -} - -async function readComposerCaret(input: Locator) { - return input.evaluate((element) => { - const selection = window.getSelection(); - if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { - return null; - } - const range = document.createRange(); - range.selectNodeContents(element); - range.setEnd(selection.anchorNode, selection.anchorOffset); - return range.toString().length; - }); -} - -async function pressPrimaryShiftM(page: Page) { - const isMac = await page.evaluate(() => - /mac|iphone|ipad|ipod/i.test(navigator.platform), - ); - await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); -} - -async function readOutgoingMentionPubkeys(page: Page, content: string) { - return page.evaluate((expectedContent) => { - const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find( - (event) => event.content === expectedContent, - ); - if (signedEvent) { - return (signedEvent.tags ?? []) - .filter((tag) => tag[0] === "p" && tag[1]) - .map((tag) => tag[1]); - } - - for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) { - if (entry.command === "send_channel_message") { - const payload = entry.payload as - | { content?: string; mentionPubkeys?: string[] } - | undefined; - if (payload?.content === expectedContent) { - return payload.mentionPubkeys ?? []; - } - } - - if (entry.command !== "plugin:websocket|send") continue; - const data = ( - entry.payload as { message?: { data?: string } } | undefined - )?.message?.data; - if (!data) continue; - - try { - const frame = JSON.parse(data) as [ - string, - { content?: string; tags?: string[][] }, - ]; - if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) { - continue; - } - return (frame[1].tags ?? []) - .filter((tag) => tag[0] === "p" && tag[1]) - .map((tag) => tag[1]); - } catch {} - } - - return null; - }, content); -} - -async function emitMockMessage( - page: Page, - content: string, - mentionPubkeys: string[], -) { - await page.evaluate( - ({ body, mentions }) => { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", - content: body, - mentionPubkeys: mentions, - }); - }, - { body: content, mentions: mentionPubkeys }, - ); -} - -async function installAudienceFixtures( - page: Page, - options: { - agentAName?: string; - deferredComposerUploads?: boolean; - sendMessageDelayMs?: number; - sendMessageErrors?: string[]; - uploadDelayMs?: number; - uploadDescriptors?: Array<{ - filename: string; - sha256: string; - size: number; - type: string; - uploaded: number; - url: string; - }>; - usersBatchDelayMs?: number; - } = {}, -) { - const { agentAName = "Morgarita", ...bridgeOptions } = options; - await installMockBridge(page, { - ...bridgeOptions, - managedAgents: [ - { - pubkey: AGENT_A, - name: agentAName, - status: "running", - channelNames: ["general"], - }, - { - pubkey: AGENT_B, - name: "Vogue", - status: "running", - channelNames: ["general"], - }, - ], - }); -} - test("keeps a queued-attachment send locked through upload and send settlement", async ({ page, }) => { @@ -267,6 +105,218 @@ test("keeps a queued-attachment send locked through upload and send settlement", await expect(composerForm).toHaveAttribute("data-submit-locked", "false"); }); +for (const surface of ["channel", "thread"] as const) { + for (const automatic of [false, true]) { + for (const mode of ["toolbar", "typed"] as const) { + test(`${surface}: ${mode} mentions respect auto=${automatic} and second-avatar removal`, async ({ + page, + }) => { + if (automatic) await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await (surface === "channel" ? openGeneral(page) : openThread(page)); + const composer = + surface === "channel" ? channelComposer(page) : threadComposer(page); + const input = composer.getByTestId("message-input"); + await addMention(composer, "Morgarita", mode); + await addMention(composer, "Vogue", mode); + await input.pressSequentially("review "); + await addMention(composer, "Vogue", mode); + await input.pressSequentially("now"); + await expect(input).toHaveText("@Morgarita @Vogue review @Vogue now"); + await expect( + composer.getByTestId("composer-address-lock-avatar"), + ).toHaveCount(2); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); + expect( + await page.evaluate( + (key) => localStorage.getItem(key) === "true", + KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY, + ), + ).toBe(automatic); + await composer + .getByRole("button", { + name: "Remove Vogue from this and future messages", + exact: true, + }) + .click(); + await expect(input).toHaveText("@Morgarita review now"); + await expect( + composer.getByTestId("composer-address-lock-avatar"), + ).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita review now")) + .toEqual([AGENT_A]); + await expect(composer.getByTestId("message-composer")).toHaveAttribute( + "data-submit-locked", + "false", + ); + await expect(input).toHaveText(automatic ? "@Morgarita " : ""); + await expect( + composer.getByTestId("composer-address-lock-avatar"), + ).toHaveCount(automatic ? 1 : 0); + await input.pressSequentially("follow-up"); + await input.press("Enter"); + await expect + .poll(() => + readOutgoingMentionPubkeys( + page, + automatic ? "@Morgarita follow-up" : "follow-up", + ), + ) + .toEqual(automatic ? [AGENT_A] : []); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); + }); + } + } +} + +test("an upload finishing cannot restore an agent removed from the next draft", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page, { + deferredComposerUploads: true, + uploadDelayMs: 3_500, + uploadDescriptors: [ + { + filename: "audience.mp4", + sha256: "c".repeat(64), + size: 16, + type: "video/mp4", + uploaded: 1, + url: `https://mock.relay/media/${"c".repeat(64)}.mp4`, + }, + ], + }); + await openThread(page); + const composer = threadComposer(page); + const input = composer.getByTestId("message-input"); + await addMention(composer, "Morgarita", "typed"); + await addMention(composer, "Vogue", "typed"); + await input.pressSequentially("queued review"); + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("queued video"), + mimeType: "video/mp4", + name: "audience.mp4", + }); + await input.press("Enter"); + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(input).toHaveText("@Morgarita @Vogue "); + // Remove the leading mention so this catches stale restoration independently + // of the second-avatar removal bug covered by the matrix above. + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText("@Vogue "); + await expect(composer.getByTestId("message-composer")).toHaveAttribute( + "data-submit-locked", + "true", + ); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect(composer.getByTestId("message-composer")).toHaveAttribute( + "data-submit-locked", + "false", + { timeout: 10_000 }, + ); + await expect( + page.getByTestId("message-row").filter({ hasText: "queued review" }).last(), + ).toBeVisible(); + await expect(input).toHaveText("@Vogue "); + await focusComposerEnd(input); + await input.pressSequentially("follow-up after upload"); + await expect(input).toHaveText("@Vogue follow-up after upload"); + await input.press("Enter"); + await expect + .poll(() => + readOutgoingMentionPubkeys(page, "@Vogue follow-up after upload"), + ) + .toEqual([AGENT_B]); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("keyboard users can inspect and remove an overflow recipient", async ({ + page, +}) => { + const agentC = "c".repeat(64); + const agentD = "d".repeat(64); + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page, { + additionalAgents: [ + { pubkey: agentC, name: "Orchid" }, + { pubkey: agentD, name: "Zinnia" }, + ], + }); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + for (const name of ["Morgarita", "Vogue", "Orchid", "Zinnia"]) { + await addMention(composer, name, "typed"); + } + await expect(input).toHaveText("@Morgarita @Vogue @Orchid @Zinnia "); + await expect( + composer.getByTestId("composer-address-lock-avatar"), + ).toHaveCount(3); + const overflow = composer.getByRole("button", { + name: "Show 1 more mentioned agents", + exact: true, + }); + await expect(overflow).toHaveText("+1"); + await composer.getByRole("button", { name: "Mention someone" }).focus(); + for (let index = 0; index < 4; index += 1) await page.keyboard.press("Tab"); + await expect(overflow).toBeFocused(); + await page.keyboard.press("Enter"); + const removeHidden = page.getByRole("button", { + name: "Remove Zinnia from this and future messages", + exact: true, + }); + await expect(removeHidden).toBeVisible(); + await expect(removeHidden).toContainText("Zinnia"); + await expect(removeHidden).toBeFocused(); + await page.keyboard.press("Space"); + await expect(removeHidden).toHaveCount(0); + await expect(overflow).toHaveCount(0); + await expect(input).toHaveText("@Morgarita @Vogue @Orchid "); + for (const pubkey of [AGENT_A, AGENT_B, agentC]) { + await expect( + composer.getByTestId(`composer-address-lock-${pubkey}`), + ).toBeVisible(); + } + await focusComposerEnd(input); + await input.pressSequentially("overflow review"); + await input.press("Enter"); + await expect + .poll(() => + readOutgoingMentionPubkeys( + page, + "@Morgarita @Vogue @Orchid overflow review", + ), + ) + .toEqual([AGENT_A, AGENT_B, agentC]); + await expect(composer.getByTestId("message-composer")).toHaveAttribute( + "data-submit-locked", + "false", + ); + await expect(input).toHaveText("@Morgarita @Vogue @Orchid "); + await expect(overflow).toHaveCount(0); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { @@ -284,7 +334,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ composer.getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); }); @@ -401,7 +451,7 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => { await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); const selectAllShortcut = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); @@ -469,9 +519,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect( - composer - .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Don't automatically mention / }), + composer.getByTestId("composer-address-locks").getByRole("button", { + name: /^Remove .+ from this and future messages$/, + }), ).toHaveCount(1); await input.fill("@Vog"); @@ -490,9 +540,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await expect( - composer - .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Don't automatically mention / }), + composer.getByTestId("composer-address-locks").getByRole("button", { + name: /^Remove .+ from this and future messages$/, + }), ).toHaveCount(1); }); @@ -521,6 +571,7 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async test("the mention button opens settings and can undo an address", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -528,7 +579,7 @@ test("the mention button opens settings and can undo an address", async ({ await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); const ingress = composer.getByRole("button", { - name: "Manage automatic agent mentions", + name: "Mention someone", }); await input.type("draft text"); @@ -537,9 +588,8 @@ test("the mention button opens settings and can undo an address", async ({ await expect(menu).toBeVisible(); await expect(input).toHaveText("@Morgarita draft text"); await page.getByTestId("mention-options-trigger").click(); - await expect( - page.getByTestId("mention-keep-agents-pinned-toggle"), - ).toBeVisible(); + const preference = page.getByTestId("mention-keep-agents-pinned-toggle"); + await expect(preference).toHaveAttribute("data-state", "checked"); const layerBox = await composer .getByTestId("mention-autocomplete-layer") .boundingBox(); @@ -579,6 +629,10 @@ test("the mention button opens settings and can undo an address", async ({ }) .click(); await expect(input).toHaveText("draft text"); + await page.getByTestId("mention-options-trigger").click(); + await expect(preference).toHaveAttribute("data-state", "checked"); + await preference.click(); + await expect(preference).toHaveAttribute("data-state", "unchecked"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); @@ -594,13 +648,19 @@ test("the mention button opens settings and can undo an address", async ({ await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText(""); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) - .toContain(AGENT_A); + .toEqual([AGENT_A]); + await expect(input).toHaveText(""); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + expect( + await page.evaluate( + (key) => localStorage.getItem(key) === "true", + KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY, + ), + ).toBe(false); }); test("always-mentioned agents remain selected without replaying their animation while Enter-send resolves", async ({ @@ -656,7 +716,7 @@ test("always-mentioned agents remain selected without replaying their animation { timeout: 500 }, ); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); @@ -835,9 +895,10 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), ).toHaveAttribute("data-state", "unchecked"); + await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); await expect(autoPinConfirmation).toHaveCount(0); await input.press("Escape"); @@ -929,7 +990,7 @@ test("automatic mentions are scoped to their channel or thread composer", async ).toHaveCount(0); }); -test("a thread automatic mention preserves an explicitly unpinned root agent", async ({ +test("a thread does not restore a removed root agent; explicit re-add follows automation", async ({ page, }) => { await keepMentionedAgentsPinned(page); @@ -950,16 +1011,6 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); - await rootInput.fill("@cla"); - await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible(); - await rootInput.press("Tab"); - await rootInput.type("one time"); - await rootInput.press("Enter"); - await expect(rootInput).toHaveText(""); - await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - await openThread(page); const activeThreadComposer = threadComposer(page); const threadInput = activeThreadComposer.getByTestId("message-input"); @@ -987,39 +1038,47 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a await restoredRootInput.press("Tab"); await expect( channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - await restoredRootInput.type("one time"); + ).toBeVisible(); + await restoredRootInput.type("explicit re-add"); await restoredRootInput.press("Enter"); - await expect(restoredRootInput).toHaveText(""); + await expect + .poll(() => + readOutgoingMentionPubkeys(page, "@claude code explicit re-add"), + ) + .toEqual([AGENT_A]); + await expect(restoredRootInput).toHaveText("@claude code "); await expect( channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); }); -test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ - page, -}) => { - await keepMentionedAgentsPinned(page); - await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - - const composer = channelComposer(page); - const input = composer.getByTestId("message-input"); - await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); - await expect(input).toHaveText(""); - await input.fill("@Mor"); - await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await input.press("Tab"); - await input.type("one time"); - await input.press("Enter"); - - await expect(input).toHaveText(""); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); -}); +for (const mode of ["toolbar", "typed"] as const) { + test(`explicit ${mode} re-add after X follows the enabled automatic setting`, async ({ + page, + }) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await composer + .getByTestId(`composer-address-lock-remove-${AGENT_A}`) + .click(); + await expect(input).toHaveText(""); + await addMention(composer, "Morgarita", mode); + await input.pressSequentially("re-added"); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita re-added")) + .toEqual([AGENT_A]); + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + }); +} test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({ page, @@ -1084,19 +1143,7 @@ test("implicit automatic mentions stay out of persisted drafts", async ({ await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraft(page)) .toBe("draft text continues"); }); @@ -1122,19 +1169,7 @@ test("an authored duplicate leading mention survives draft restoration", async ( }); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraft(page)) .toBe("@Morgarita authored duplicate"); }); @@ -1162,20 +1197,7 @@ test("typed deletion preserves an identical authored mention in drafts", async ( waitUntil: "domcontentloaded", }); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraft(page)) .toBe("@Morgarita manual after typed deletion"); }); @@ -1195,20 +1217,7 @@ test("removing an automatic mention preserves an identical authored mention in d }); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraft(page)) .toBe("@Morgarita manual after removal"); }); @@ -1230,22 +1239,7 @@ test("multiple automatic mentions stay out of persisted drafts", async ({ await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); - await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) - .toBe("draft text"); + await expect.poll(() => readPersistedDraft(page)).toBe("draft text"); }); test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({ @@ -1273,19 +1267,7 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra }); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraft(page)) .toBe("@Morgarita authored duplicate"); }); @@ -1319,7 +1301,7 @@ test("a restored multi-word automatic mention remains a chip with the caret afte composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); await page.waitForTimeout(500); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); @@ -1390,7 +1372,7 @@ test("the mention-button placement fits the narrow composer", async ({ await automaticallyMention(overlay, "Vogue"); await expect(overlay.getByTestId("composer-address-locks")).toBeVisible(); await expect( - overlay.getByRole("button", { name: "Manage automatic agent mentions" }), + overlay.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); diff --git a/desktop/tests/helpers/persistentAgentAudience.ts b/desktop/tests/helpers/persistentAgentAudience.ts new file mode 100644 index 0000000000..7440bf9fc1 --- /dev/null +++ b/desktop/tests/helpers/persistentAgentAudience.ts @@ -0,0 +1,278 @@ +import type { TiptapEditorHTMLElement } from "@tiptap/core"; +import { expect, type Locator, type Page } from "@playwright/test"; + +import { installMockBridge } from "./bridge"; + +export const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +export const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; +export const AGENT_A = "a".repeat(64); +export const AGENT_B = "b".repeat(64); +export const THREAD_ROOT_ID = "mock-general-welcome"; +export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = + "buzz.messages.keepMentionedAgentsPinned"; + +export async function keepMentionedAgentsPinned(page: Page) { + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, "true"); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +} + +export async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") { + await page.addInitScript( + ({ selectedTheme, selectedAccent }) => { + window.localStorage.setItem("buzz-theme", selectedTheme); + window.localStorage.setItem("buzz-accent-color", selectedAccent); + }, + { selectedTheme: theme, selectedAccent: accent }, + ); +} + +export async function automaticallyMention( + composer: ReturnType, + displayName: string, +) { + await composer.locator("[data-mention-picker-trigger]").click(); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: `Automatically mention ${displayName}` }) + .click(); + await expect(composer.getByTestId("message-input")).toContainText( + `@${displayName}`, + ); + await composer.locator("[data-mention-picker-trigger]").click(); +} + +export async function openGeneral(page: Page) { + await page.goto(`/#/channels/${CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +} + +export async function openThread(page: Page, threadRootId = THREAD_ROOT_ID) { + await page.goto( + `/#/channels/${CHANNEL_ID}?messageId=${threadRootId}&thread=${threadRootId}`, + { waitUntil: "domcontentloaded" }, + ); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); +} + +export function channelComposer(page: Page) { + return page.getByTestId("channel-composer-overlay"); +} + +export function threadComposer(page: Page) { + return page.getByTestId("thread-composer-overlay"); +} + +export async function readComposerCaret(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return range.toString().length; + }); +} + +/** Assert selection without moving it, so autocomplete regressions stay visible. */ +export async function expectComposerCaretAtEnd(input: Locator) { + const readSelection = () => + input.evaluate((element) => { + const editor = (element as TiptapEditorHTMLElement).editor; + const selection = window.getSelection(); + if (!editor || !selection?.anchorNode || !selection.focusNode) + return null; + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return { + focused: document.activeElement === element, + collapsed: selection.isCollapsed && editor.state.selection.empty, + atEnd: range.toString().length === element.textContent?.length, + agreesWithEditor: + editor.view.posAtDOM(selection.anchorNode, selection.anchorOffset) === + editor.state.selection.anchor && + editor.view.posAtDOM(selection.focusNode, selection.focusOffset) === + editor.state.selection.head, + }; + }); + const expected = { + focused: true, + collapsed: true, + atEnd: true, + agreesWithEditor: true, + }; + await expect.poll(readSelection).toEqual(expected); + await input.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); + expect(await readSelection()).toEqual(expected); +} + +/** Explicit append setup after an edit that intentionally moves the caret. */ +export async function focusComposerEnd(input: Locator) { + await input.evaluate((element) => { + const editor = (element as TiptapEditorHTMLElement).editor; + if (!editor || editor.isDestroyed) throw new Error("Composer not mounted"); + editor.commands.focus("end", { scrollIntoView: false }); + }); + await expectComposerCaretAtEnd(input); +} + +export async function pressPrimaryShiftM(page: Page) { + const isMac = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform), + ); + await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); +} + +export async function readOutgoingMentionPubkeys(page: Page, content: string) { + return page.evaluate((expectedContent) => { + const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find( + (event) => event.content === expectedContent, + ); + if (signedEvent) { + return (signedEvent.tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } + + for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) { + if (entry.command === "send_channel_message") { + const payload = entry.payload as + | { content?: string; mentionPubkeys?: string[] } + | undefined; + if (payload?.content === expectedContent) { + return payload.mentionPubkeys ?? []; + } + } + + if (entry.command !== "plugin:websocket|send") continue; + const data = ( + entry.payload as { message?: { data?: string } } | undefined + )?.message?.data; + if (!data) continue; + + try { + const frame = JSON.parse(data) as [ + string, + { content?: string; tags?: string[][] }, + ]; + if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) { + continue; + } + return (frame[1].tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } catch {} + } + + return null; + }, content); +} + +export async function readPersistedDraft(page: Page) { + return page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + if (drafts[channelId]) return drafts[channelId].content ?? ""; + } + return ""; + }, CHANNEL_ID); +} + +export async function addMention( + composer: Locator, + name: string, + mode: "toolbar" | "typed", +) { + const input = composer.getByTestId("message-input"); + if (mode === "toolbar") { + await composer.getByTestId("message-insert-mention").click(); + await composer + .getByRole("button", { name: `Mention ${name}`, exact: true }) + .click(); + } else { + await input.pressSequentially(`@${name.slice(0, 3)}`); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + } + await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + // Do not press End: on macOS it cancels chip-edge settlement without + // moving the caret. Verify the natural autocomplete selection instead. + await expectComposerCaretAtEnd(input); +} + +export async function emitMockMessage( + page: Page, + content: string, + mentionPubkeys: string[], +) { + await page.evaluate( + ({ body, mentions }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + mentionPubkeys: mentions, + }); + }, + { body: content, mentions: mentionPubkeys }, + ); +} + +export async function installAudienceFixtures( + page: Page, + options: { + agentAName?: string; + additionalAgents?: Array<{ pubkey: string; name: string }>; + deferredComposerUploads?: boolean; + sendMessageDelayMs?: number; + sendMessageErrors?: string[]; + uploadDelayMs?: number; + uploadDescriptors?: Array<{ + filename: string; + sha256: string; + size: number; + type: string; + uploaded: number; + url: string; + }>; + usersBatchDelayMs?: number; + } = {}, +) { + const { + agentAName = "Morgarita", + additionalAgents = [], + ...bridgeOptions + } = options; + await installMockBridge(page, { + ...bridgeOptions, + managedAgents: [ + { + pubkey: AGENT_A, + name: agentAName, + status: "running", + channelNames: ["general"], + }, + { + pubkey: AGENT_B, + name: "Vogue", + status: "running", + channelNames: ["general"], + }, + ...additionalAgents.map((agent) => ({ + ...agent, + status: "running" as const, + channelNames: ["general"], + })), + ], + }); +}