diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index d7516fe6912..b30008109c1 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -9,25 +9,25 @@ globalThis.localStorage = { const preference = await import("./autoPinMentionedAgentsPreference.ts"); -test("defaults missing and invalid values to keeping mentioned agents pinned", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true); +test("defaults missing and invalid values to one-time agent mentions", () => { + assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); + assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); }); test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); + preference.setKeepMentionedAgentsPinned(true); + assert.equal(preference.getKeepMentionedAgentsPinned(), true); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", + "true", ); - preference.setKeepMentionedAgentsPinned(true); - assert.equal(preference.getKeepMentionedAgentsPinned(), true); + preference.setKeepMentionedAgentsPinned(false); + assert.equal(preference.getKeepMentionedAgentsPinned(), false); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "true", + "false", ); }); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 0a3821b4e37..8f8e0b12d65 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -2,7 +2,7 @@ import * as React from "react"; export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true; +export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; const listeners = new Set<() => void>(); let keepMentionedAgentsPinned = readStoredPreference(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 2a78e881320..a3e0fcf9197 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -114,6 +114,10 @@ function storageKey(): string { : legacyStorageKey(); } +export function getDraftStoreScope(): string { + return storageKey(); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 98c600e87f1..d4db6f5d173 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -170,32 +170,33 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - useDraftPersistLifecycle({ - effectiveDraftKey, - channelId, - loadDraft: drafts.loadDraft, - persistDraft: drafts.persistDraft, - getMentionRefs: mentions.getDraftMentionRefs, - restoreMentionRefs: mentions.restoreDraftMentionRefs, - livePendingImeta: media.pendingImeta, - setPendingImeta: media.setPendingImeta, - getQueuedAttachments: () => media.queuedAttachmentsRef.current, - saveQueuedAttachmentsForDraft, - clearQueuedAttachments: media.clearQueuedAttachments, - restoreQueuedAttachments: media.restoreQueuedAttachments, - takeQueuedAttachmentsForDraft, - setContent: (content) => { - setComposerContent(content); - richText.setContent(content); - }, - clearContent: () => { - setComposerContent(""); - richText.clearContent(); - }, - setSpoileredAttachmentUrls, - spoileredAttachmentUrlsRef, - syncComposerContentFromEditor, - }); + const { trackAuthoredContent: trackDraftAuthoredContent } = + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + getMentionRefs: mentions.getDraftMentionRefs, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { media.setUploadState({ status: "idle" }); @@ -272,6 +273,8 @@ function MessageComposerImpl({ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { + trackDraftAuthoredContent(text); + contentRef.current = text; setComposerContentFromText(text); setPreviewContent(linkPreviewContent); if (!isSubmitLockedRef.current && !editTargetRef.current) { diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b2..e5f08b6ebdc 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); +test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => { + const DRAFT_KEY = "chan-clear-race"; + setupStore("pubkey-clear-race"); + persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "draft text"); + + trackAuthoredContent(""); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "the authoritative update removes the stale body even while editor reads lag", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "async settlement must not repersist the deleted body", + ); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "the deleted body must not be restored"); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-image"; + setupStore("pubkey-clear-caption-image"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]); + + let editorContent = ""; + let pendingImeta = []; + let spoileredUrls = new Set(); + let trackAuthoredContent; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredUrls = urls; + }, + spoileredAttachmentUrlsRef: { + get current() { + return spoileredUrls; + }, + }, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(pendingImeta[0]?.url, IMG_A.url); + assert.deepEqual([...spoileredUrls], [IMG_A.url]); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, ""); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-file"; + setupStore("pubkey-clear-caption-file"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 9, + spoilered: true, + }; + + let editorContent = ""; + let queuedAttachments = [FILE_A]; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + assert.equal(queuedAttachments[0]?.spoilered, true); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => { + const DRAFT_KEY = "shared-key"; + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore("pubkey-a", "wss://relay-a.example"); + persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const workspaceA = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await workspaceA.unmount(); + + initDraftStore("pubkey-b", "wss://relay-b.example"); + persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []); + const workspaceB = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "workspace B"); + await workspaceB.unmount(); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B"); + + initDraftStore("pubkey-a", "wss://relay-a.example"); + const workspaceARemount = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "workspace A stale text must stay cleared"); + await workspaceARemount.unmount(); +}); + test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { setupStore("pubkey-switch-files"); const FILE_A = { diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adbc..694bf6a2a55 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,9 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { - DraftMentionRef, - DraftState, +import { + getDraftStoreScope, + type DraftMentionRef, + type DraftState, } from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { @@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = { syncComposerContentFromEditor: () => string; }; +type UseDraftPersistLifecycleResult = { + /** + * Record the latest authored editor content. Empty content is persisted + * immediately and remains authoritative across composer remounts until a + * later non-empty editor update supersedes it. + */ + trackAuthoredContent: (content: string) => void; +}; + +const authoritativelyClearedDraftKeys = new Set(); + +function scopedDraftKey(draftKey: string): string { + return `${getDraftStoreScope()}:${draftKey}`; +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, -}: UseDraftPersistLifecycleParams): void { +}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); + const emptyContentIsAuthoritativeRef = React.useRef(false); + const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], ); @@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({ pendingImetaForPersistRef.current = livePendingImeta; // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { + React.useLayoutEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); + const authoritativeDraftKey = effectiveDraftKey + ? scopedDraftKey(effectiveDraftKey) + : null; + const wasAuthoritativelyCleared = authoritativeDraftKey + ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) + : false; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; + isRestoringContentRef.current = true; if (saved) { - setContent(saved.content); - restoreMentionRefs(saved.mentionRefs ?? []); + const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; + setContent(restoredContent); + restoreMentionRefs( + wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []), + ); // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async // state setter, so the cleanup closure (which may fire before the state // update commits in React StrictMode's simulate-unmount pass) reads the @@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({ setPendingImeta([]); setSpoileredAttachmentUrls(new Set()); } + isRestoringContentRef.current = false; return () => { if (effectiveDraftKey) { @@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const content = emptyContentIsAuthoritativeRef.current + ? "" + : syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, content, @@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + const trackAuthoredContent = React.useCallback( + (content: string) => { + if (!effectiveDraftKey || isRestoringContentRef.current) return; + const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + if (content.length > 0) { + authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = false; + return; + } + authoritativelyClearedDraftKeys.add(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = true; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + ); + }, + [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], + ); + + return { trackAuthoredContent }; } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 2136e2e12f5..edcf523e36c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({ await expect(mentionChip).toHaveText("Fizz"); }); -test("managed relay-profile agents with member roles use the agent address tray", async ({ +test("managed relay-profile agents with member roles can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -1286,9 +1286,17 @@ test("managed relay-profile agents with member roles use the agent address tray" await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); - await input.press("Enter"); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); + await expect(charlieRow.getByText("agent")).toBeVisible(); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); @@ -2618,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); -test("selecting a managed non-member agent from a DM addresses it", async ({ +test("a managed non-member agent from a DM can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -2638,10 +2646,18 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); await expect(autocomplete(page)).toHaveCount(1); await expect(input.locator(".mention-chip")).toHaveCount(0); - await input.press("Enter"); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index f9481c91f05..9a02e2e1a87 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; 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) => { + window.localStorage.removeItem(storageKey); + }, 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( @@ -271,7 +285,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab immediately selects a manually mentioned agent", async ({ page }) => { +test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -286,7 +300,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); const selectAllShortcut = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); @@ -301,6 +315,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -448,7 +463,7 @@ test("the mention button opens settings and can undo an address", async ({ .toContain(AGENT_A); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(1); + ).toHaveCount(0); }); test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({ @@ -568,9 +583,10 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected immediately", async ({ +test("a manual mention persists when automatic mentions are enabled", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); await openGeneral(page); @@ -652,6 +668,7 @@ test("a manually mentioned agent becomes selected immediately", async ({ test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -717,6 +734,7 @@ test("reduced motion removes addressed agents without spatial animation", async page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page);