diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9d61e9c365..a26c5cd835 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -164,6 +164,8 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); + const hydrationRef = + React.useRef>(null); useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -189,6 +191,7 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + draftContentResolverRef: hydrationRef, }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { @@ -273,7 +276,7 @@ function MessageComposerImpl({ mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); - persistentMentionHydrationRef.current?.reconcile(text); + hydrationRef.current?.reconcile(text); if (text.trim().length > 0) { notifyTyping(); } @@ -298,10 +301,7 @@ function MessageComposerImpl({ richText, }); const persistentAudience = persistentMentionHydration.audience; - const persistentMentionHydrationRef = React.useRef( - persistentMentionHydration, - ); - persistentMentionHydrationRef.current = persistentMentionHydration; + hydrationRef.current = persistentMentionHydration; const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b..c6d8813291 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -193,6 +193,7 @@ function installDOMShim() { } }; globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); } installDOMShim(); @@ -233,6 +234,7 @@ import { act } from "react"; // Production hook under test — owns the restore effect, cleanup, and the // synchronous ref write that is the StrictMode fix. import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; +import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration.ts"; // Real storage functions — the test uses them, not a replica. import { @@ -246,6 +248,11 @@ import { saveQueuedAttachmentsForDraft, takeQueuedAttachmentsForDraft, } from "../lib/backgroundMediaUploadStore.ts"; +import { + getPersistentAgentAudienceScope, + setPersistentAgentAudience, + setPersistentAgentAudienceEnabled, +} from "../lib/persistentAgentAudience.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -291,6 +298,142 @@ async function mountStrictMode(Comp) { }; } +const AUDIENCE_OWNER_PUBKEY = "1".repeat(64); +const AGENT_ADA = { + displayName: "Agent Ada", + pubkey: "a".repeat(64), +}; +const AGENT_BEA = { + displayName: "Agent Bea", + pubkey: "b".repeat(64), +}; + +function createPersistentAudienceDraftHarness({ agents, initialRoute }) { + setupStore(AUDIENCE_OWNER_PUBKEY); + setPersistentAgentAudienceEnabled(false); + setPersistentAgentAudienceEnabled(true); + + let route = initialRoute; + let editorContent = ""; + let setPendingImetaFromTest = () => {}; + const spoileredRef = { current: new Set() }; + const hydrationRef = { current: null }; + const agentByPubkey = new Map(agents.map((agent) => [agent.pubkey, agent])); + + const scopeFor = (candidate) => { + const scope = getPersistentAgentAudienceScope({ + ownerPubkey: AUDIENCE_OWNER_PUBKEY, + channelId: candidate.channelId, + threadRootId: candidate.threadRootId, + }); + assert.ok(scope); + return scope; + }; + const seedRouteAudience = (candidate) => { + setPersistentAgentAudience(scopeFor(candidate), candidate.agentPubkeys); + }; + seedRouteAudience(route); + + const extractMentionPubkeys = (content) => + agents + .filter((agent) => content.includes(`@${agent.displayName}`)) + .map((agent) => agent.pubkey); + const mentions = { + cancelMentionAutocomplete: () => {}, + clearMentions: () => {}, + extractMentionPubkeys, + getMentionDisplayName: (pubkey) => + agentByPubkey.get(pubkey)?.displayName ?? null, + insertResolvedMention: ({ + replaceFromOffset, + replaceToOffset, + displayName, + }) => ({ + replaceFromOffset, + replaceToOffset, + insertText: `@${displayName} `, + }), + registerMentionPubkey: () => {}, + }; + const richText = { + getMarkdown: () => editorContent, + getPlainTextAndCursor: () => ({ + cursor: editorContent.length, + text: editorContent, + }), + replacePlainTextRange: (from, to, text) => { + editorContent = + editorContent.slice(0, from) + text + editorContent.slice(to); + }, + }; + + function HarnessComposer() { + const [pendingImeta, setPendingImeta] = React.useState([]); + setPendingImetaFromTest = setPendingImeta; + + useDraftPersistLifecycle({ + effectiveDraftKey: route.draftKey, + channelId: route.channelId, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: (content) => + extractMentionPubkeys(content).map((pubkey) => ({ + displayName: agentByPubkey.get(pubkey).displayName, + pubkey, + isAgent: true, + })), + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + draftContentResolverRef: hydrationRef, + }); + + hydrationRef.current = usePersistentAgentMentionHydration({ + audienceScope: scopeFor(route), + hydrationKey: route.draftKey, + isEditing: false, + mentions, + richText, + }); + return null; + } + + return { + HarnessComposer, + addAttachment: async (attachment) => { + await act(async () => { + setPendingImetaFromTest([attachment]); + }); + }, + appendEditorContent: (content) => { + editorContent += content; + }, + getEditorContent: () => editorContent, + resolvePostSendContent: (pubkeys) => { + editorContent = hydrationRef.current.resolvePostSendContent(pubkeys); + }, + switchRoute: (nextRoute) => { + seedRouteAudience(nextRoute); + route = nextRoute; + }, + }; +} + +async function flushPersistentAudienceHydration() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + // ── Tests ───────────────────────────────────────────────────────────────────── /** @@ -662,3 +805,156 @@ test("discarding_a_draft_drops_its_retained_local_files", () => { assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); }); + +test("persistent audience hydration alone does not create a thread draft", async (t) => { + t.after(() => setPersistentAgentAudienceEnabled(false)); + const route = { + agentPubkeys: [AGENT_ADA.pubkey], + channelId: "channel-audience-only", + draftKey: "thread:audience-only", + threadRootId: "audience-only", + }; + const harness = createPersistentAudienceDraftHarness({ + agents: [AGENT_ADA], + initialRoute: route, + }); + + const handle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + assert.equal( + harness.getEditorContent(), + `@${AGENT_ADA.displayName} `, + "precondition: opening the thread hydrates its saved agent audience", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(route.draftKey), + undefined, + "preselected agent recipients are not authored draft content", + ); + + const authoredHandle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + harness.appendEditorContent("please investigate"); + await authoredHandle.unmount(); + assert.equal( + loadDraftEntry(route.draftKey)?.content, + `@${AGENT_ADA.displayName} please investigate`, + "user-authored content still persists with its preselected recipient", + ); +}); + +test("persistent audience hydration preserves an existing mention-only draft", async (t) => { + t.after(() => setPersistentAgentAudienceEnabled(false)); + const route = { + agentPubkeys: [AGENT_ADA.pubkey], + channelId: "channel-existing-mention", + draftKey: "thread:existing-mention", + threadRootId: "existing-mention", + }; + const harness = createPersistentAudienceDraftHarness({ + agents: [AGENT_ADA], + initialRoute: route, + }); + const savedContent = `@${AGENT_ADA.displayName} `; + persistDraftEntry( + route.draftKey, + savedContent, + route.channelId, + [], + [], + [{ ...AGENT_ADA, isAgent: true }], + ); + + const handle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + await handle.unmount(); + + assert.equal( + loadDraftEntry(route.draftKey)?.content, + savedContent, + "restored authored content is never mistaken for programmatic hydration", + ); +}); + +test("persistent audience hydration keeps an attachment draft without mention text", async (t) => { + t.after(() => setPersistentAgentAudienceEnabled(false)); + const route = { + agentPubkeys: [AGENT_ADA.pubkey], + channelId: "channel-audience-attachment", + draftKey: "thread:audience-attachment", + threadRootId: "audience-attachment", + }; + const harness = createPersistentAudienceDraftHarness({ + agents: [AGENT_ADA], + initialRoute: route, + }); + + const handle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + await harness.addAttachment(IMG_A); + await handle.unmount(); + + const saved = loadDraftEntry(route.draftKey); + assert.ok(saved, "the attachment keeps the draft alive"); + assert.equal(saved.content, "", "hydrated recipients are not draft text"); + assert.deepEqual(saved.pendingImeta, [IMG_A]); +}); + +test("persistent audience hydration does not leak ghost drafts across thread switches", async (t) => { + t.after(() => setPersistentAgentAudienceEnabled(false)); + const routeA = { + agentPubkeys: [AGENT_ADA.pubkey], + channelId: "channel-switch", + draftKey: "thread:switch-a", + threadRootId: "switch-a", + }; + const routeB = { + agentPubkeys: [AGENT_BEA.pubkey], + channelId: "channel-switch", + draftKey: "thread:switch-b", + threadRootId: "switch-b", + }; + const harness = createPersistentAudienceDraftHarness({ + agents: [AGENT_ADA, AGENT_BEA], + initialRoute: routeA, + }); + + const handle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + assert.equal(harness.getEditorContent(), `@${AGENT_ADA.displayName} `); + + await act(async () => { + harness.switchRoute(routeB); + }); + await handle.rerender(); + await flushPersistentAudienceHydration(); + assert.equal(loadDraftEntry(routeA.draftKey), undefined); + assert.equal(harness.getEditorContent(), `@${AGENT_BEA.displayName} `); + + await handle.unmount(); + assert.equal(loadDraftEntry(routeB.draftKey), undefined); +}); + +test("persistent audience post-send recipients do not become a draft", async (t) => { + t.after(() => setPersistentAgentAudienceEnabled(false)); + const route = { + agentPubkeys: [AGENT_ADA.pubkey], + channelId: "channel-post-send", + draftKey: "thread:post-send", + threadRootId: "post-send", + }; + const harness = createPersistentAudienceDraftHarness({ + agents: [AGENT_ADA], + initialRoute: route, + }); + + const handle = await mountStrictMode(harness.HarnessComposer); + await flushPersistentAudienceHydration(); + harness.resolvePostSendContent([AGENT_ADA.pubkey]); + assert.equal(harness.getEditorContent(), `@${AGENT_ADA.displayName} `); + await handle.unmount(); + + assert.equal(loadDraftEntry(route.draftKey), undefined); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adb..e8998787fc 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -58,6 +58,10 @@ type UseDraftPersistLifecycleParams = { * closure to capture the latest text before the effect fires. */ syncComposerContentFromEditor: () => string; + /** Resolver for editor-only state that should not become authored content. */ + draftContentResolverRef?: React.RefObject<{ + resolveDraftContentForPersistence: (content: string) => string; + } | null>; }; /** @@ -104,6 +108,7 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + draftContentResolverRef, }: UseDraftPersistLifecycleParams): void { const pendingImetaForPersistRef = React.useRef([]); const restoredQueuedAttachmentsRef = React.useRef( @@ -160,7 +165,11 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const editorContent = syncComposerContentFromEditor(); + const content = + draftContentResolverRef?.current?.resolveDraftContentForPersistence( + editorContent, + ) ?? editorContent; persistDraft( effectiveDraftKey, content, diff --git a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts index d2e0d4390e..78f846a410 100644 --- a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts +++ b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts @@ -34,6 +34,7 @@ export function usePersistentAgentMentionHydration({ const isSubmittingRef = React.useRef(false); const cancelHydrationAutocompleteRef = React.useRef(false); const hydratedRef = React.useRef(false); + const hydrationOnlyDraftContentRef = React.useRef(null); const hydrate = React.useCallback(() => { const capturedScope = audienceScope; @@ -48,6 +49,8 @@ export function usePersistentAgentMentionHydration({ } isRestoringRef.current = true; const current = richText.getPlainTextAndCursor().text; + const startedWithoutDraftContent = + richText.getMarkdown().trim().length === 0; const targets = audience.pubkeys .map((pubkey) => ({ pubkey, @@ -86,6 +89,13 @@ export function usePersistentAgentMentionHydration({ } hydratedRef.current = scopeRef.current === capturedScope; isRestoringRef.current = false; + if ( + hydrationOnlyDraftContentRef.current === null && + startedWithoutDraftContent && + hydratedRef.current + ) { + hydrationOnlyDraftContentRef.current = richText.getMarkdown(); + } if (cancelHydrationAutocompleteRef.current) { cancelHydrationAutocompleteRef.current = false; // Hydration is a programmatic transition, not an authored query. Cancel @@ -124,10 +134,20 @@ export function usePersistentAgentMentionHydration({ React.useEffect(() => { void hydrationKey; hydratedRef.current = false; + hydrationOnlyDraftContentRef.current = null; const frame = scheduleHydration(); return () => cancelAnimationFrame(frame); }, [hydrationKey, scheduleHydration]); + const resolveDraftContentForPersistence = React.useCallback( + (content: string) => + hydrationOnlyDraftContentRef.current !== null && + content === hydrationOnlyDraftContentRef.current + ? "" + : content, + [], + ); + const resolvePostSendContent = React.useCallback( (explicitAgentPubkeys: string[]) => { if (!audience.enabled || !audienceScope || isEditingRef.current) @@ -151,10 +171,11 @@ export function usePersistentAgentMentionHydration({ } isRestoringRef.current = true; hydratedRef.current = true; - return ( + const postSendContent = targets.map((target) => `@${target.displayName}`).join(" ") + - (targets.length > 0 ? " " : "") - ); + (targets.length > 0 ? " " : ""); + hydrationOnlyDraftContentRef.current = postSendContent; + return postSendContent; }, [audience.enabled, audience.pubkeys, audienceScope, mentions], ); @@ -169,6 +190,7 @@ export function usePersistentAgentMentionHydration({ scheduleHydration(true); }, reconcile, + resolveDraftContentForPersistence, resolvePostSendContent, scheduleHydration, }; diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index ae424b4e5c..fa094e2604 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -10,6 +10,7 @@ const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; const SCOPE = `${OWNER}:${CHANNEL_ID}:thread:${THREAD_ROOT_ID}`; +const DRAFT_STORE_KEY = `buzz-drafts.v2:ws://localhost:3000:${OWNER}`; async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") { await page.addInitScript( @@ -260,6 +261,34 @@ test("persistent agents restore through the native inline mention UI", async ({ await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); }); +test("leaving an untouched persistent-agent thread does not create a draft", async ({ + page, +}) => { + await seedAudience(page, [AGENT_A]); + await installAudienceFixtures(page); + await openThread(page); + + const input = threadComposer(page).getByTestId("message-input"); + await expect(input).toHaveText("@Morgarita "); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + + await openGeneral(page); + await expect + .poll(() => + page.evaluate( + ({ draftKey, storeKey }) => { + const drafts = JSON.parse(localStorage.getItem(storeKey) ?? "{}"); + return drafts[draftKey] ?? null; + }, + { + draftKey: `thread:${THREAD_ROOT_ID}`, + storeKey: DRAFT_STORE_KEY, + }, + ), + ) + .toBeNull(); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures native persistent mentions in ${theme}`, async ({ page }) => { await seedAudience(page, [AGENT_A, AGENT_B], theme);