From db566eb57d71b8141fcd1c60799a144acbfaa7f4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 13:03:16 -0400 Subject: [PATCH 1/4] fix(desktop): retain drafts after synchronous mention extraction failure Replay the approved first slice from 857ed203 and e0cc5522 (aggregate 58db416d). Keep extraction within failure cleanup; use the existing runtime lookup and upload error rendering through size-gate helper extractions. Remove the inherited unused markdown import for strict builds. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../messages/ui/ComposerUploadError.tsx | 20 ++++++ .../features/messages/ui/MessageComposer.tsx | 17 ++--- .../ui/useMentionAvailableRuntimes.ts | 28 +++++++++ .../ui/useMentionSendFlow.authority.test.mjs | 32 ++++++++++ .../ui/useMentionSendFlow.test-support.mjs | 4 ++ .../messages/ui/useMentionSendFlow.ts | 63 ++++++++----------- desktop/src/shared/ui/markdown.tsx | 1 - 7 files changed, 116 insertions(+), 49 deletions(-) create mode 100644 desktop/src/features/messages/ui/ComposerUploadError.tsx create mode 100644 desktop/src/features/messages/ui/useMentionAvailableRuntimes.ts diff --git a/desktop/src/features/messages/ui/ComposerUploadError.tsx b/desktop/src/features/messages/ui/ComposerUploadError.tsx new file mode 100644 index 00000000000..96b3b36128f --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadError.tsx @@ -0,0 +1,20 @@ +import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; + +/** Display the upload failure without losing the composer's retry controls. */ +export function ComposerUploadError({ + uploadState, + onDismiss, +}: { + uploadState: ReturnType["uploadState"]; + onDismiss: () => void; +}) { + if (uploadState.status !== "error") return null; + return ( +
+ Upload failed: {uploadState.message} + +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index ed437969063..bf6bf1abbb9 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -52,6 +52,7 @@ import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { focusMentionOptionsTrigger } from "./MentionAutocomplete"; import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadError } from "./ComposerUploadError"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useComposerVoiceNote } from "./useComposerVoiceNote"; @@ -897,18 +898,10 @@ function MessageComposerImpl({ onOptionsRevealComplete={completeMentionOptionsReveal} onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent} /> - {media.uploadState.status === "error" ? ( -
- Upload failed: {media.uploadState.message} - -
- ) : null} + media.setUploadState({ status: "idle" })} + /> {composerLinkPreviews} => { + const cached = availableRuntimesQuery.data ?? []; + if (cached.length > 0 || !availableRuntimesQuery.isLoading) { + return cached; + } + const refetched = await availableRuntimesQuery.refetch(); + return (refetched.data ?? []).filter( + (runtime): runtime is AcpRuntime => + runtime.availability === "available" && + runtime.command !== null && + runtime.binaryPath !== null, + ); + }, [ + availableRuntimesQuery.data, + availableRuntimesQuery.isLoading, + availableRuntimesQuery.refetch, + ]); + return getAvailableRuntimes; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs index 9d73f7b7b59..6621b43cd28 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs @@ -216,3 +216,35 @@ test("automatic addressed prefix is programmatic optimistic empty, not new autho assert.equal(s.store.get("thread:a").content, TEXT); assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); }); + +for (const extractor of [ + "getDraftMentionRefs", + "extractMentionPubkeys", + "extractMentionPersonas", +]) { + test(`synchronous ${extractor} failure is visible and releases the send latch`, async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + s.options.mentions[extractor] = () => { + throw new Error("Choose an exact recipient"); + }; + s.rerender(); + const input = { + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + recoveryDraftKey: "thread:a", + sentDraftKey: "thread:a", + }; + for (let attempt = 0; attempt < 2; attempt++) { + await s.act(async () => + s.result.current.sendMessageWithMentionFlow(input), + ); + assert.equal(s.result.current.isPreparingMentionSend, false); + assert.equal(s.options.contentRef.current, TEXT); + assert.equal(s.events("error").length, attempt + 1); + assert.equal(s.events("error").at(-1)[1], "Choose an exact recipient"); + assert.equal(s.events("SEND").length, 0); + } + }); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs index 9702463f18e..305afd4f986 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -159,6 +159,10 @@ export async function setup({ lifecycle = false } = {}) { AgentMentionAuthorizationError: class extends Error {}, }, }; + stubs["./useMentionAvailableRuntimes"] = load( + "useMentionAvailableRuntimes", + stubs, + ); stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs); stubs["./useActivePreparedLinkPreviews"] = load( "useActivePreparedLinkPreviews", diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index addc6a22eb0..4585a2360a1 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -4,7 +4,6 @@ import { toast } from "sonner"; import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, - useAvailableAcpRuntimes, useCreateChannelManagedAgentMutation, useManagedAgentsQuery, usePersonasQuery, @@ -15,6 +14,7 @@ import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents" import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; +import { useMentionAvailableRuntimes } from "./useMentionAvailableRuntimes"; import { useNonMemberInvite } from "./useNonMemberInvite"; import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; import { @@ -27,7 +27,7 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; +import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -111,7 +111,6 @@ export function useMentionSendFlow({ useCreateChannelManagedAgentMutation(channelId); const provisionPersonaAgentMutation = useProvisionChannelManagedAgentMutation(channelId); - const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); const startAgentMutation = useStartManagedAgentMutation(); @@ -127,25 +126,7 @@ export function useMentionSendFlow({ const getPersonas = React.useCallback(async () => { return personasQuery.data ?? (await personasQuery.refetch()).data ?? []; }, [personasQuery.data, personasQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< - AcpRuntime[] - > => { - const cached = availableRuntimesQuery.data ?? []; - if (cached.length > 0 || !availableRuntimesQuery.isLoading) { - return cached; - } - const refetched = await availableRuntimesQuery.refetch(); - return (refetched.data ?? []).filter( - (runtime): runtime is AcpRuntime => - runtime.availability === "available" && - runtime.command !== null && - runtime.binaryPath !== null, - ); - }, [ - availableRuntimesQuery.data, - availableRuntimesQuery.isLoading, - availableRuntimesQuery.refetch, - ]); + const getAvailableRuntimes = useMentionAvailableRuntimes(); const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -669,7 +650,11 @@ export function useMentionSendFlow({ await finishSend(uploaded, signal); } catch (error) { restoreComposerAfterFailure(); - toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); + toast.error( + error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error), + ); } finally { settleUpload(); } @@ -697,7 +682,11 @@ export function useMentionSendFlow({ await finishSend([]); } catch (error) { restoreComposerAfterFailure(); - toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); + toast.error( + error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error), + ); } } } catch (error) { @@ -762,20 +751,22 @@ export function useMentionSendFlow({ } isMentionSendPendingRef.current = true; setIsMentionSendPending(true); - // Capture exact selections before any async preparation can navigate the - // reused editor to another draft (possibly with identical display text). - claimDraftSend(effectiveDraftKey); - const composerRevision = getComposerRevision(); - const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice(); - const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed); - const selectedPersonas = mentions.extractMentionPersonas(trimmed); - const isSendCancelled = () => - preparedLinkPreviews?.signal.aborted === true; let sendPromoted = false; - if (preparedLinkPreviews) { - activePreparedLinkPreviews.add(preparedLinkPreviews); - } try { + // Capture exact selections before any async preparation can navigate the + // reused editor to another draft (possibly with identical display text). + // Ambiguous-name extraction can throw; it owns the same visible failure + // and pending-state cleanup as the asynchronous preparation below. + claimDraftSend(effectiveDraftKey); + const composerRevision = getComposerRevision(); + const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice(); + const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed); + const selectedPersonas = mentions.extractMentionPersonas(trimmed); + const isSendCancelled = () => + preparedLinkPreviews?.signal.aborted === true; + if (preparedLinkPreviews) { + activePreparedLinkPreviews.add(preparedLinkPreviews); + } if (isSendCancelled()) return; const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({ trimmed, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 178f0c4b209..176321eea11 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,7 +14,6 @@ import { type ParsedMessageLink, } from "@/features/messages/lib/messageLink"; import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment"; -import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; import { parseEntityLink } from "@/shared/lib/entityLink"; From b7f124f7fc7565e214d3f1b62699dff82d471ba2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 1 Sep 2026 21:59:44 -0400 Subject: [PATCH 2/4] fix(desktop): keep closed message actions from taking edit focus Backport the independently reviewed 646f30febd436d8a10a2f531f38a4ac9d2912064 fix and its regression to published PR1 db566eb5. Keep the closed-menu production owner and browser assertions unchanged; register the test next to mention-recipients on this base. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + .../features/messages/ui/MessageActionBar.tsx | 3 + desktop/tests/e2e/message-edit-focus.spec.ts | 86 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 desktop/tests/e2e/message-edit-focus.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a3d8412de40..d530e0a1a2a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -77,6 +77,7 @@ export default defineConfig({ "**/remote-owned-mentions.spec.ts", "**/mention-spacing.spec.ts", "**/mention-recipients.spec.ts", + "**/message-edit-focus.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 02dcdcaf6d1..5364a05058b 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -157,6 +157,9 @@ function MoreActionsMenu({ { diff --git a/desktop/tests/e2e/message-edit-focus.spec.ts b/desktop/tests/e2e/message-edit-focus.spec.ts new file mode 100644 index 00000000000..24fff4b500b --- /dev/null +++ b/desktop/tests/e2e/message-edit-focus.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +// Hold exit presence, not composer readiness: a closed menu can still receive a +// queued pointer-leave while its CSS exit animation is mounted. +const holdExit = ` + [role="menu"][data-state="closed"] { animation-play-state: paused !important; } +`; + +test("closing message menu cannot reclaim editor focus on pointer leave", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.addStyleTag({ content: holdExit }); + const input = page.getByTestId("message-input"); + await input.fill("focus ownership"); + await page.getByTestId("send-message").click(); + const row = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await expect(row).toContainText("focus ownership"); + await row.hover(); + await row.getByRole("button", { name: "More actions" }).click(); + const edit = page.getByRole("menuitem", { name: "Edit message" }); + const editElement = await edit.elementHandle(); + await edit.click(); + await expect(input).toHaveText("focus ownership"); + // A deliberate user click establishes focus independently of RAF timing. + await input.click(); + await input.press("ControlOrMeta+End"); + await expect(input).toBeFocused(); + await page.keyboard.type(" edit"); + await expect(input).toHaveText("focus ownership edit"); + const closing = await editElement?.evaluate((element) => { + const menu = element.closest('[role="menu"]'); + const state = menu?.getAttribute("data-state"); + const connected = element.isConnected; + element.dispatchEvent( + new PointerEvent("pointerout", { + bubbles: true, + pointerType: "mouse", + relatedTarget: document.body, + }), + ); + return { state, connected }; + }); + expect(closing).toEqual({ state: "closed", connected: true }); + await expect(input).toBeFocused(); + await page.keyboard.type("ed"); + await expect(input).toHaveText("focus ownership edited"); + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate(() => { + const call = window.__BUZZ_E2E_COMMAND_LOG__ + ?.filter((entry) => entry.command === "edit_message") + .at(-1); + return (call?.payload as { input?: { content: string } })?.input + ?.content; + }), + ) + .toBe("focus ownership edited"); +}); + +test("message menu Escape still restores its keyboard trigger", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const row = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await row.hover(); + const trigger = row.getByRole("button", { name: "More actions" }); + await trigger.focus(); + await trigger.press("Enter"); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); From d0c3498409a8d25a9c4663547439312783aec96c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 2 Sep 2026 11:35:24 -0400 Subject: [PATCH 3/4] fix(desktop): preserve draft authority and generated mention labels Package existing reviewed repairs in the authorized seven-slice dependency stack. Preserved model A; experiment excluded. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/messages/ui/MessageComposer.tsx | 10 +- .../useAddressedAgentMentionRestore.test.mjs | 180 ++++++++++++++++++ .../ui/useAddressedAgentMentionRestore.ts | 54 ++++-- ...useImplicitAgentMentionProvenance.test.mjs | 152 +++++++++++++++ .../ui/useImplicitAgentMentionProvenance.ts | 12 +- .../src/shared/styles/globals/composer.css | 3 + desktop/tests/e2e/mention-recipients.spec.ts | 143 ++++++++++++++ 7 files changed, 530 insertions(+), 24 deletions(-) create mode 100644 desktop/src/features/messages/ui/useAddressedAgentMentionRestore.test.mjs create mode 100644 desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.test.mjs diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index bf6bf1abbb9..9b409cb93b5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -352,6 +352,8 @@ function MessageComposerImpl({ audiencePubkeys: persistentAudience.pubkeys, channelId, enabled: keepMentionedAgentsPinned, + getComposerRevision, + runComposerUpdate, }); const mentionSendFlow = useMentionSendFlow({ getComposerRevision, @@ -458,7 +460,7 @@ function MessageComposerImpl({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, - restoreAddressedAgentMentions, + restoreAddressedAgentMentions: restoreAgentMentions, selectMentionSuggestion, syncAddressedAgentsFromText, toggleAlwaysAddressAgent, @@ -484,11 +486,11 @@ function MessageComposerImpl({ richText, }); addressedMentionRestore.restoreAddressedAgentMentionsRef.current = - restoreAddressedAgentMentions; + restoreAgentMentions; React.useLayoutEffect(() => { if (!audienceScope || editTarget != null) return; - restoreAddressedAgentMentions(); - }, [audienceScope, editTarget, restoreAddressedAgentMentions]); + runComposerUpdate(() => restoreAgentMentions()); + }, [audienceScope, editTarget, restoreAgentMentions, runComposerUpdate]); syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.test.mjs b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.test.mjs new file mode 100644 index 00000000000..ef06e5bf8e5 --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, test } from "node:test"; +import { JSDOM } from "jsdom"; +import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore.ts"; +import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; +import { + claimDraftSend, + clearAllDrafts, + deleteDraftEntry, + getDraftAuthority, + initDraftStore, + loadDraftEntry, + persistDraftEntry, + recordDraftAuthoredContent, +} from "../lib/useDrafts.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const { act, cleanup, renderHook } = await import("@testing-library/react"); +after(() => { + cleanup(); + dom.window.close(); +}); +let frames; +let nextFrame = 0; +beforeEach(() => { + cleanup(); + clearAllDrafts(); + initDraftStore("author", "wss://restore.example"); + frames = new Map(); + // Keep cancelled callbacks invocable: authority must reject an already dequeued frame too. + globalThis.requestAnimationFrame = (cb) => { + frames.set(++nextFrame, cb); + return nextFrame; + }; + globalThis.cancelAnimationFrame = () => {}; +}); +const A = "a".repeat(64); +const B = "b".repeat(64); +function mount() { + let text = ""; + const writes = []; + let lifecycle; + const hook = renderHook( + ({ key, channelId, enabled }) => { + lifecycle = useDraftPersistLifecycle({ + effectiveDraftKey: key, + channelId, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (value) => { + text = value; + }, + clearContent: () => { + text = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: { current: new Set() }, + syncComposerContentFromEditor: () => text, + }); + const restore = useAddressedAgentMentionRestore({ + audiencePubkeys: [A, B], + channelId, + enabled, + getComposerRevision: lifecycle.getComposerRevision, + runComposerUpdate: lifecycle.runComposerUpdate, + }); + restore.restoreAddressedAgentMentionsRef.current = (keys, allowed) => { + writes.push([keys, allowed]); + text = "@Agent Ada "; + lifecycle.trackAuthoredContent(text); + return text; + }; + return restore; + }, + { initialProps: { key: "A", channelId: "channel", enabled: true } }, + ); + return { + ...hook, + writes, + author: (value) => + act(() => { + text = value; + lifecycle.trackAuthoredContent(value); + }), + schedule: (keys = [A]) => { + act(() => hook.result.current.onAddressedAgentsSendSucceeded(keys, keys)); + return nextFrame; + }, + release: (id) => act(() => frames.get(id)(0)), + visit: (key, enabled = true) => + hook.rerender({ key, channelId: "channel", enabled }), + }; +} + +for (const [name, supersede] of [ + ["authored content", (h) => h.author("follow up")], + ["authored empty", (h) => h.author("")], + ["other visit authors empty", () => recordDraftAuthoredContent("A", "")], + ["explicit deletion of absent value", () => deleteDraftEntry("A")], + ["new send", () => claimDraftSend("A")], + [ + "scope reset round trip", + () => { + initDraftStore("other"); + initDraftStore("author", "wss://restore.example"); + }, + ], + [ + "same-channel draft visit round trip", + (h) => { + h.visit("B"); + h.visit("A"); + }, + ], + [ + "disable then enable", + (h) => { + h.visit("A", false); + h.visit("A"); + }, + ], + ["unmount", (h) => h.unmount()], +]) { + test(`delayed automatic restore loses authority after ${name}`, () => { + const h = mount(); + const frame = h.schedule(); + supersede(h); + h.release(frame); + assert.deepEqual(h.writes, []); + }); +} + +test("no author restores exact captured keys without manufacturing authored intent", () => { + const h = mount(); + const authority = getDraftAuthority("A"); + const revision = authority.revision; + const frame = h.schedule([B]); + h.release(frame); + assert.deepEqual(h.writes, [[[B], [B]]]); + assert.equal(authority.revision, revision); + assert.equal(authority.authoredRevision, 0); +}); +test("restore first then author, and unrelated key authoring, preserve authority", () => { + const h = mount(); + const frame = h.schedule(); + recordDraftAuthoredContent("B", "other"); + h.release(frame); + h.author(""); + assert.equal(h.writes.length, 1); + assert.equal(getDraftAuthority("A").emptyContentIsAuthoritative, true); +}); +test("a replaced frame cannot restore old recipients or consume the current frame", () => { + const h = mount(); + const old = h.schedule([A]); + const current = h.schedule([B]); + h.release(old); + assert.deepEqual(h.writes, []); + h.release(current); + assert.deepEqual(h.writes, [[[B], [B]]]); +}); +test("synchronous automatic clear restoration is programmatic too", () => { + const h = mount(); + const revision = getDraftAuthority("A").revision; + act(() => h.result.current.onAddressedAgentsComposerCleared([A])); + assert.equal(getDraftAuthority("A").revision, revision); + assert.deepEqual(h.writes, [[[A], undefined]]); +}); diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts index 9bc88ea6b47..3c8af668e23 100644 --- a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts @@ -9,30 +9,48 @@ export function useAddressedAgentMentionRestore({ audiencePubkeys, channelId, enabled, + getComposerRevision, + runComposerUpdate, }: { audiencePubkeys: readonly string[]; channelId: string | null; enabled: boolean; + getComposerRevision: () => number; + runComposerUpdate: (update: () => void) => void; }) { const restoreAddressedAgentMentionsRef = React.useRef(() => ""); const restoreFrameRef = React.useRef(null); - const channelIdRef = React.useRef(channelId); - channelIdRef.current = channelId; - React.useEffect( + // biome-ignore lint/correctness/useExhaustiveDependencies: revoke pending writes on owner/setting transitions + React.useLayoutEffect( () => () => { if (restoreFrameRef.current !== null) { cancelAnimationFrame(restoreFrameRef.current); + restoreFrameRef.current = null; } }, - [], + // Accessor identity owns a draft visit, not just a channel (including A→B→A). + [channelId, enabled, getComposerRevision], ); const onAddressedAgentsComposerCleared = React.useCallback( - (pubkeys: readonly string[]) => - restoreAddressedAgentMentionsRef.current(pubkeys), - [], + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys?: readonly string[], + ) => { + let content = ""; + // All automatic restorations share the draft owner's programmatic boundary. + // They must not manufacture authored intent, especially authored emptiness. + runComposerUpdate(() => { + content = restoreAddressedAgentMentionsRef.current( + pubkeys, + allowedUnpinnedPubkeys, + ); + }); + return content; + }, + [runComposerUpdate], ); const onAddressedAgentsSendSucceeded = React.useCallback( (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { @@ -42,20 +60,26 @@ export function useAddressedAgentMentionRestore({ ); if (!enabled || confirmedPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; + const revision = getComposerRevision(); if (restoreFrameRef.current !== null) { cancelAnimationFrame(restoreFrameRef.current); } - restoreFrameRef.current = requestAnimationFrame(() => { + const frame = requestAnimationFrame(() => { + if (restoreFrameRef.current !== frame) return; restoreFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current( - pubkeys, - confirmedPinnedPubkeys, - ); + // Recheck shared authority at execution, not only at send settlement. + // Authoring (even empty), deletion, reset and newer sends revoke it. + if (getComposerRevision() !== revision) return; + onAddressedAgentsComposerCleared(pubkeys, confirmedPinnedPubkeys); }); + restoreFrameRef.current = frame; }, - [audiencePubkeys, channelId, enabled], + [ + audiencePubkeys, + enabled, + getComposerRevision, + onAddressedAgentsComposerCleared, + ], ); return { diff --git a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.test.mjs b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.test.mjs new file mode 100644 index 00000000000..d8b753cdba5 --- /dev/null +++ b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { after, beforeEach, test } from "node:test"; +import { JSDOM } from "jsdom"; +import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance.ts"; +import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; +import { + clearAllDrafts, + initDraftStore, + loadDraftEntry, + persistDraftEntry, +} from "../lib/useDrafts.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const { act, cleanup, renderHook } = await import("@testing-library/react"); +beforeEach(() => { + cleanup(); + clearAllDrafts(); + initDraftStore("author", "wss://provenance.example"); +}); +after(() => { + cleanup(); + dom.window.close(); +}); +const A = "a".repeat(64); +const B = "b".repeat(64); +const fragment = (pubkey, label) => ({ pubkey, prefix: `@${label} ` }); + +function mount() { + let text = ""; + const hook = renderHook( + ({ key }) => { + const provenance = useImplicitAgentMentionProvenance(key); + const lifecycle = useDraftPersistLifecycle({ + effectiveDraftKey: key, + channelId: key, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (value) => { + text = value; + }, + clearContent: () => { + text = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: { current: new Set() }, + syncComposerContentFromEditor: () => text, + getImplicitAgentMentionPrefix: provenance.getPrefix, + }); + return { provenance, lifecycle }; + }, + { initialProps: { key: "channel" } }, + ); + return { + ...hook, + text: () => text, + visit: (key) => hook.rerender({ key }), + // Same boundary as picker insertion: record exact fragments before updating text. + generate: (fragments) => + act(() => { + hook.result.current.lifecycle.runComposerUpdate(() => { + hook.result.current.provenance.add(fragments); + text = fragments.map(({ prefix }) => prefix).join(""); + hook.result.current.lifecycle.trackAuthoredContent(text); + }); + }), + author: (value) => + act(() => { + text = value; + hook.result.current.lifecycle.trackAuthoredContent(value); + }), + }; +} + +for (const [oldLabel, newLabel] of [ + [`Scout (${B})`, "Scout"], + ["Scout", "Scout Jones"], + ["Scout Jones", `Scout Jones (${B})`], +]) { + for (const body of ["", "authored body", `@${newLabel} authored duplicate`]) { + test(`${oldLabel} → ${newLabel}: persists only ${JSON.stringify(body)}`, () => { + const h = mount(); + h.generate([fragment(B, oldLabel)]); + const revision = h.result.current.lifecycle.getComposerRevision(); + h.generate([fragment(B, newLabel)]); + assert.equal(h.result.current.lifecycle.getComposerRevision(), revision); + if (body) h.author(`@${newLabel} ${body}`); + h.visit("other"); + assert.equal(loadDraftEntry("channel")?.content ?? "", body); + h.visit("channel"); + assert.equal(h.text(), body); + assert.equal(h.result.current.provenance.getPrefix(), `@${newLabel} `); + }); + } +} + +test("new insertion order and fragments replace known keys, preserving other keys", () => { + const h = mount(); + const p = () => h.result.current.provenance; + act(() => p().add([fragment(A, "Scout"), fragment(B, `Scout (${B})`)])); + act(() => p().add([fragment(B, "Scout Jones")])); + assert.equal(p().getPrefix(), "@Scout Jones @Scout "); + act(() => p().add([fragment(A, "Scout"), fragment(B, "Scout Jones")])); + assert.equal(p().getPrefix(), "@Scout @Scout Jones "); + act(() => p().remove(A)); + assert.equal(p().getPrefix(), "@Scout Jones "); + act(() => p().remove(B)); + assert.equal(p().getPrefix(), ""); + h.author("@Scout Jones authored after removal"); + h.visit("other"); + assert.equal( + loadDraftEntry("channel").content, + "@Scout Jones authored after removal", + ); +}); + +test("changed generated label cannot override authored empty authority", () => { + const h = mount(); + h.generate([fragment(B, `Scout (${B})`)]); + h.author(""); + const revision = h.result.current.lifecycle.getComposerRevision(); + h.generate([fragment(B, "Scout")]); + assert.equal(h.result.current.lifecycle.getComposerRevision(), revision); + h.visit("other"); + assert.equal(loadDraftEntry("channel"), undefined); + h.visit("channel"); + assert.equal(h.text(), ""); +}); + +test("changed labels are draft scoped and absent keys do not capture provenance", () => { + const h = mount(); + h.generate([fragment(B, `Scout (${B})`)]); + h.visit("other"); + h.generate([fragment(B, "Scout")]); + h.visit("channel"); + assert.equal(h.result.current.provenance.getPrefix(), `@Scout (${B}) `); + h.visit(null); + h.generate([fragment(B, "No Draft")]); + assert.equal(h.result.current.provenance.getPrefix(), ""); +}); diff --git a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts index c426ee1df76..930f584d6c6 100644 --- a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts +++ b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts @@ -24,14 +24,16 @@ export function useImplicitAgentMentionProvenance( (insertedFragments: readonly GeneratedMention[]) => { if (!effectiveDraftKey) return; const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; - const knownPubkeys = new Set( - fragments.map((fragment) => fragment.pubkey), + const insertedPubkeys = new Set( + insertedFragments.map((fragment) => fragment.pubkey), ); + // Identity survives label resets, but generated text and prepend order do + // not. The latest insertion owns that key's exact fragment for stripping. byDraftRef.current.set(effectiveDraftKey, [ - ...insertedFragments.filter( - (fragment) => !knownPubkeys.has(fragment.pubkey), + ...insertedFragments, + ...fragments.filter( + (fragment) => !insertedPubkeys.has(fragment.pubkey), ), - ...fragments, ]); trimMapToSize(byDraftRef.current, 200); }, diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 5ebe4b57757..268277165cb 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -97,6 +97,9 @@ outline: none; min-height: 1lh; padding-block: 0.0625rem; + /* Keep literal separators even when TipTap's injected style is absent. + Collapsed trailing spaces let native input replace a restored boundary. */ + white-space: break-spaces; } .rich-text-composer .tiptap p { diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 6daa3e61894..b9064dd8853 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -1,3 +1,4 @@ +import type { Editor } from "@tiptap/core"; import { expect, test, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -330,3 +331,145 @@ test("editing to a longer typed member drops the original shorter reference", as .toEqual({ references: [], notifying: [SECOND] }); await expect(row).toContainText("Scout Jones hello"); }); + +for (const name of ["Morgarita", "claude code", "Scout"]) { + test(`restored ${name} separator survives missing injected editor styles`, async ({ + page, + }) => { + const keys = ["a".repeat(64), "b".repeat(64)]; + const selected = name === "Scout" ? keys.slice(1) : keys.slice(0, 1); + await installMockBridge(page, { + managedAgents: keys.map((pubkey) => ({ + pubkey, + name, + status: "running", + channelNames: ["general"], + })), + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const composer = page.getByTestId("channel-composer-overlay"); + const input = composer.getByTestId("message-input"); + await composer.locator("[data-mention-picker-trigger]").click(); + for (const key of name === "Scout" ? keys : selected) { + await composer.getByTestId(`mention-always-address-${key}`).click(); + } + if (name === "Scout") { + await composer.getByTestId(`mention-always-address-${keys[0]}`).click(); + } + await composer.locator("[data-mention-picker-trigger]").click(); + const initialPrefix = `@${name}${name === "Scout" ? ` (${keys[1]})` : ""} `; + await expect(input).toHaveText(initialPrefix); + await input.pressSequentially("hello"); + await input.press("Enter"); + await expect + .poll(() => recipients(page, `${initialPrefix}hello`)) + .toEqual([selected]); + // Send clears label registrations. The new draft can use the bare label, + // but it must still bind only the surviving identity (B for Scout). + const prefix = `@${name} `; + await expect(input).toHaveText(prefix); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + // Generated-only text must not become an authored draft on navigation. + expect( + await page.evaluate(() => + Object.keys(localStorage) + .filter((key) => key.startsWith("buzz-drafts.v2:")) + .flatMap((key) => + Object.values(JSON.parse(localStorage.getItem(key) ?? "{}")), + ), + ), + ).toEqual([]); + await page.getByTestId("channel-general").click(); + await expect(input).toHaveText(prefix); + // Check hydration before removing styles or typing: whitespace-normalized + // toHaveText alone cannot distinguish a lost separator. + await expect + .poll(() => + input.evaluate((element) => { + const editor = (element as HTMLElement & { editor: Editor }).editor; + return [ + editor.state.doc.textContent, + editor.state.selection.from, + editor.state.selection.to, + ]; + }), + ) + .toEqual([prefix, prefix.length + 1, prefix.length + 1]); + await expect(input).toBeFocused(); + await expect(input.locator(".agent-mention-highlight")).toHaveCount( + selected.length, + ); + // The reproduced failure had no injected TipTap whitespace stylesheet. + // Remove only that transient dependency, not app CSS or editor selection. + await input.evaluate(() => { + document.querySelector("style[data-tiptap-style]")?.remove(); + }); + await expect + .poll(() => + input.evaluate((element) => { + const editor = (element as HTMLElement & { editor: Editor }).editor; + return { + text: editor.state.doc.textContent, + from: editor.state.selection.from, + to: editor.state.selection.to, + }; + }), + ) + .toEqual({ + text: prefix, + from: prefix.length + 1, + to: prefix.length + 1, + }); + await input.pressSequentially("follow-up"); + await expect(input).toHaveText(`${prefix}follow-up`); + await expect(input).toHaveCSS("white-space", "break-spaces"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount( + selected.length, + ); + await input.press("Enter"); + await expect + .poll(() => recipients(page, `${prefix}follow-up`)) + .toEqual([selected]); + // Inspect all kind-9 publications, not just a matching body: no extra A send. + await expect + .poll(() => + page.evaluate(() => { + type Event = { kind: number; content: string; tags: string[][] }; + const summarize = (events: Event[]) => + events + .filter((event) => event.kind === 9) + .map((event) => ({ + content: event.content, + p: event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + })); + const wire = (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => entry.command === "plugin:websocket|send") + .flatMap((entry) => { + const data = (entry.payload as { message?: { data?: string } }) + ?.message?.data; + if (!data) return []; + const frame = JSON.parse(data); + return frame[0] === "EVENT" ? [frame[1]] : []; + }); + return { + signed: summarize(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []), + wire: summarize(wire), + }; + }), + ) + .toEqual({ + signed: [ + { content: `${initialPrefix}hello`, p: selected }, + { content: `${prefix}follow-up`, p: selected }, + ], + wire: [ + { content: `${initialPrefix}hello`, p: selected }, + { content: `${prefix}follow-up`, p: selected }, + ], + }); + }); +} From bb8cca21e13099bc6206b18c62272b72928574ca Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 07:11:24 -0400 Subject: [PATCH 4/4] test(desktop): own exact recipient restoration and publication evidence Keep one full storage-to-transport witness and both local restoration checks. Select the destination fixture by pointer so this prefix does not depend on the later native Enter repair. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/tests/e2e/mentions.spec.ts | 4 +- .../e2e/persistent-agent-audience.spec.ts | 77 +++++++++++++++++++ .../tests/e2e/send-channel-binding.spec.ts | 36 ++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e392eda5c0c..d430231bda5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -549,7 +549,7 @@ test("relay-only shared agents emit an outbound mention tag when selected", asyn await expect .poll(() => readOutgoingMentionPubkeys(page, content)) - .toContain(TEST_IDENTITIES.alice.pubkey); + .toEqual([TEST_IDENTITIES.alice.pubkey]); }); test("typing an exact agent name and Space commits its chip and mention tag", async ({ @@ -580,7 +580,7 @@ test("typing an exact agent name and Space commits its chip and mention tag", as await page.getByTestId("send-message").click(); await expect .poll(() => readOutgoingMentionPubkeys(page, content)) - .toContain(TEST_IDENTITIES.alice.pubkey); + .toEqual([TEST_IDENTITIES.alice.pubkey]); }); test("Shift+Space leaves an exact agent name plain and emits no mention tag", async ({ diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 6cc2f22a883..39e8c1004d2 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -137,6 +137,76 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +// Exercise the persistence/identity boundary after the duplicate-chip check. +async function assertAuthoredDuplicateIdentity(page: Page) { + await expect + .poll(() => + page.evaluate((channelId) => { + for (const key of Object.keys(localStorage)) { + if (!key.startsWith("buzz-drafts.v2:")) continue; + const draft = JSON.parse(localStorage.getItem(key) ?? "{}")[ + channelId + ]; + if (draft) return draft.mentionRefs; + } + return null; + }, CHANNEL_ID), + ) + .toEqual([{ displayName: "Morgarita", pubkey: AGENT_A, isAgent: true }]); + expect( + await page.evaluate(() => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter( + (event) => event.kind === 9, + ), + ), + ).toEqual([]); + + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); + await composer.getByTestId("send-message").click(); + const expected = [{ content: "@Morgarita authored duplicate", p: [AGENT_A] }]; + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.kind === 9) + .map((event) => ({ + content: event.content, + p: event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]), + })), + ), + ) + .toEqual(expected); + await expect + .poll(() => + page.evaluate(() => { + const events: { content: string; p: string[] }[] = []; + for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) { + if (entry.command !== "plugin:websocket|send") continue; + const data = (entry.payload as { message?: { data?: string } }) + ?.message?.data; + if (!data) continue; + const frame = JSON.parse(data); + if (frame[0] !== "EVENT" || frame[1]?.kind !== 9) continue; + events.push({ + content: frame[1].content, + p: frame[1].tags + .filter((tag: string[]) => tag[0] === "p") + .map((tag: string[]) => tag[1]), + }); + } + return events; + }), + ) + .toEqual(expected); +} + async function emitMockMessage( page: Page, content: string, @@ -1318,6 +1388,7 @@ test("an authored duplicate leading mention survives draft restoration", async ( }, CHANNEL_ID), ) .toBe("@Morgarita authored duplicate"); + await assertAuthoredDuplicateIdentity(page); }); test("typed deletion preserves an identical authored mention in drafts", async ({ @@ -1469,6 +1540,12 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra }, CHANNEL_ID), ) .toBe("@Morgarita authored duplicate"); + await openGeneral(page); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); }); test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({ diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index 3a7fdf7613a..3c3cd048a79 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -53,6 +53,15 @@ test("message with agent mention lands in compose-time channel despite mid-send }) => { const MESSAGE_TEXT = `send-binding-repro-${Date.now()}`; + // This case exercises a persistent address; ordinary Enter completion is + // one-time by default. Opt in before the bridge mounts the composer. + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz.messages.keepMentionedAgentsPinned", + "true", + ); + }); + // Install bridge with: // - a managed agent that is NOT in general (forces add_channel_members path) // - a 500ms delay on add_channel_members to open the race window @@ -81,7 +90,7 @@ test("message with agent mention lands in compose-time channel despite mid-send await expect(botRow).toBeVisible(); await expect(botRow.getByText("not in channel")).toBeVisible(); // Select BotA from the autocomplete - await input.press("Enter"); + await botRow.click(); await page.keyboard.type(` ${MESSAGE_TEXT}`); // Verify the inline mention and persistent address are present before submitting. @@ -127,6 +136,31 @@ test("message with agent mention lands in compose-time channel despite mid-send MESSAGE_TEXT, ); + // The signed event must retain both the compose-time channel and exact key. + await expect + .poll(() => + page.evaluate( + (text) => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.content.includes(text)) + .map((event) => ({ + channels: event.tags + .filter((tag) => tag[0] === "h") + .map((tag) => tag[1]), + recipients: event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + })), + MESSAGE_TEXT, + ), + ) + .toEqual([ + { + channels: ["9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"], + recipients: [OUT_OF_CHANNEL_BOT_PUBKEY], + }, + ]); + // --- Assert message did NOT land in agents (switched-to channel) --- await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents");