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"],
+ })),
+ ],
+ });
+}