diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8b56ec8af99..ea3c2647865 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -754,7 +754,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} message.id === item.conversationId) + ?.tags ?? []) + : []; const contextLabel = isThreadContext ? isDirectMessage ? `Thread with ${item.senderLabel}` @@ -804,7 +808,14 @@ function InboxMessageDetailPane({ />
{ assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); }); +test("initialization preserves exclusions across thread remounts", async () => { + const store = await loadStore(14); + const scope = `${ownerA}:channel-a:thread-a`; + + store.initializePersistentAgentAudience(scope, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); + + store.excludePersistentAgentAudienceMember(scope, agentA); + store.initializePersistentAgentAudience(scope, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [] }); + + store.addPersistentAgentAudienceMember(scope, agentA); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + test("explicit re-selection reinstates an excluded agent", async () => { const store = await loadStore(13); const scope = `${ownerA}:channel-a:channel`; diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 78819e0c735..bda3ecabd4d 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -160,6 +160,23 @@ export function removePersistentAgentAudienceMembersIfUnchanged({ return true; } +export function initializePersistentAgentAudience( + scope: string, + pubkeys: Iterable, +): void { + if (!scope) return; + const excluded = excludedPubkeysByScope.get(scope); + const initialPubkeys = normalizePubkeys(pubkeys).filter( + (pubkey) => + !(audiences[scope] ?? []).includes(pubkey) && !excluded?.has(pubkey), + ); + if (initialPubkeys.length === 0) return; + setPersistentAgentAudience(scope, [ + ...(audiences[scope] ?? []), + ...initialPubkeys, + ]); +} + export function addPersistentAgentAudienceMember( scope: string, pubkey: string, diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 5dfc851bfd2..91c69a962ed 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -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: "Manage mentions", }); 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: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)bg-primary\/15(?:\s|$)/, ); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)text-primary(?:\s|$)/, ); assert.doesNotMatch( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)bg-accent\/70(?:\s|$)/, ); assert.doesNotMatch( @@ -110,7 +110,13 @@ test("mention control expands with automatically mentioned agents", async () => /scale\(0.8\)/, ); } - const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); + const remove = view.getByRole("button", { + name: "Don't automatically mention Agent Ada in this thread", + }); + assert.equal( + remove.getAttribute("aria-label")?.includes("conversation"), + false, + ); const removeChrome = remove.querySelector("span.absolute"); assert.match( removeChrome?.className ?? "", diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index 795d07c380f..5ce8e117fb0 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -180,11 +180,7 @@ export function ComposerMentionButton({ + + Automatically mention agents + + + Address selected agents in thread replies + + + event.preventDefault()} + /> +
) : null} - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, same as the options surface — here it covers presses on the scrollbar and the list's padding ring. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard keeps padding and scrollbar presses from blurring the owning editor. */}
{isAlwaysAddressed - ? "Don't automatically mention in this conversation" + ? "Don't automatically mention in this thread" : "Automatically mention"} {alwaysAddressShortcut ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index ecdfb014312..888370e7a39 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -28,14 +28,8 @@ import { import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership"; import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { - getPersistentAgentAudienceScope, - usePersistentAgentAudience, -} from "@/features/messages/lib/persistentAgentAudience"; -import { - setKeepMentionedAgentsPinned, - useKeepMentionedAgentsPinned, -} from "@/features/messages/lib/autoPinMentionedAgentsPreference"; +import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import { setKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; import { useIdentityQuery } from "@/shared/api/hooks"; import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { @@ -66,6 +60,7 @@ import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance"; +import { useThreadAgentAudience } from "./useThreadAgentAudience"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; @@ -326,8 +321,12 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentAudience = usePersistentAgentAudience(audienceScope); - const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); + const { audience: persistentAudience, keepMentionedAgentsPinned } = + useThreadAgentAudience({ + isAgentPubkey: mentions.isAgentPubkey, + rootTags: audienceContext?.rootTags ?? [], + scope: audienceScope, + }); const addressPulse = useAddressMentionPulse(); const { completeOptionsReveal: completeMentionOptionsReveal, @@ -425,16 +424,11 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); - // ── Focus on reply ────────────────────────────────────────────────── - // Use focusPreserve so that re-renders (e.g. new messages arriving in - // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { if (!replyTarget || composerDisabled) return; richText.focusPreserve(); }, [composerDisabled, replyTarget, richText.focusPreserve]); useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); - // Hooks return a plain-text edit descriptor; `replacePlainTextRange` - // applies it as a single ProseMirror transaction (no markdown round-trip). const applyAutocompleteEdit = React.useCallback( (edit: AutocompleteEdit) => { richText.replacePlainTextRange( @@ -511,10 +505,6 @@ function MessageComposerImpl({ const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; - // A `:shortcode:` for a known custom emoji becomes a selectable atom - // node (same as the input rule / autocomplete), so it can be selected, - // copied, and deleted as one unit. Everything else (native unicode) - // inserts as plain content. const match = /^:([^:\s]+):$/.exec(emoji); const shortcode = match?.[1]?.toLowerCase(); const known = @@ -891,7 +881,11 @@ function MessageComposerImpl({ onEmojiSelect={applyEmojiInsert} onMentionSelect={selectMentionSuggestion} onOptionsRevealComplete={completeMentionOptionsReveal} - onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent} + onToggleAlwaysAddressAgent={(suggestion) => + toggleAlwaysAddressAgent(suggestion, { + preserveMention: true, + }) + } /> {media.uploadState.status === "error" ? (
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index ac3e40d57d4..5704988e306 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -25,7 +25,8 @@ export type MessageComposerEditTarget = { export type MessageComposerProps = { audienceContext?: { - type: "channel" | "thread"; + rootTags?: readonly string[][]; + type: "thread"; } | null; channelId?: string | null; channelName: string; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 16ee26bcc79..1de7b140fdb 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -830,7 +830,10 @@ export function MessageThreadPanel({ > { +test("only thread conversation hosts opt into persistent audiences", async () => { const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all( [ source("../../channels/ui/ChannelPane.tsx"), @@ -16,9 +16,12 @@ test("supported conversation hosts opt into explicit audience contexts", async ( ], ); - assert.match(channelPane, /audienceContext=\{\{ type: "channel" \}\}/); + assert.doesNotMatch(channelPane, /audienceContext=/); assert.doesNotMatch(newMessage, /audienceContext=/); - assert.match(threadPanel, /audienceContext=\{\{ type: "thread" \}\}/); + assert.match( + threadPanel, + /audienceContext=\{\{[\s\S]*type: "thread",[\s\S]*rootTags: threadHead\.tags,[\s\S]*\}\}/, + ); assert.match(inboxDetail, /type: "thread"/); assert.doesNotMatch(threadPanel, /audienceContext=\{[\s\S]*threadRootId/); assert.doesNotMatch(inboxDetail, /audienceContext=\{[\s\S]*threadRootId/); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 8ed155234fa..60ead286164 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -140,7 +140,7 @@ test("always addressing a new agent delegates the first add for immediate confir assert.deepEqual(pulsedPubkeys, []); }); -test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { +test("unpinning an addressed agent keeps its current mention and autocomplete open", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -187,20 +187,17 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", ); act(() => { - result.current.toggleAlwaysAddressAgent({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); + result.current.toggleAlwaysAddressAgent( + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { preserveMention: true }, + ); }); - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 4, - replaceToOffset: 15, - insertText: "", - }, - ]); + assert.deepEqual(appliedEdits, []); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 4a9ca5f15c1..4dfe40b14b0 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -14,45 +14,6 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import type { ComposerAddressAgent } from "./ComposerAddressControls"; import type { MentionSuggestion } from "./MentionAutocomplete"; -function buildMentionRemovalEdits( - text: string, - displayNames: readonly string[], - queryRange?: { start: number; end: number }, -): AutocompleteEdit[] { - const ranges = displayNames.flatMap((displayName) => - getMentionOffsets(text, displayName).map((start) => { - let end = start + `@${displayName}`.length; - if (text[end] === " ") end += 1; - return { start, end }; - }), - ); - if (queryRange) { - ranges.push({ - start: Math.max(0, Math.min(queryRange.start, text.length)), - end: Math.max(0, Math.min(queryRange.end, text.length)), - }); - } - - const merged = ranges - .filter(({ start, end }) => start < end) - .sort((left, right) => left.start - right.start) - .reduce>((result, range) => { - const previous = result.at(-1); - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - result.push({ ...range }); - } - return result; - }, []); - - return merged.reverse().map(({ start, end }) => ({ - replaceFromOffset: start, - replaceToOffset: end, - insertText: "", - })); -} - export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience, @@ -177,13 +138,21 @@ export function useAgentAddressLockPicker({ ], ); - const removeAddressedAgent = React.useCallback( + const unpinAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; unpinnedAgentPubkeysRef.current.add(normalized); const excludePubkey = audience.excludePubkey ?? audience.removePubkey; excludePubkey(normalized); + }, + [audience.excludePubkey, audience.removePubkey, audienceScope], + ); + const removeAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + unpinAddressedAgent(normalized); const displayName = lockedAgents.find( (agent) => agent.pubkey === normalized, )?.displayName; @@ -206,43 +175,27 @@ export function useAgentAddressLockPicker({ }, [ applyAutocompleteEdit, - 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, - richText.getPlainTextAndCursor, + unpinAddressedAgent, ], ); const toggleAlwaysAddressAgent = React.useCallback( - (suggestion: MentionSuggestion) => { + ( + suggestion: MentionSuggestion, + options: { preserveMention?: boolean } = {}, + ) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgentMentions(pubkey); + if (options.preserveMention) { + unpinAddressedAgent(pubkey); + } else { + removeAddressedAgent(pubkey); + } setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); @@ -313,9 +266,10 @@ export function useAgentAddressLockPicker({ onAddressAgentMention, onImplicitPrefixInserted, onPulseAddressLock, - removeAddressedAgentMentions, + removeAddressedAgent, richText.getPlainTextAndCursor, trackMentionAddressedAgent, + unpinAddressedAgent, ], ); diff --git a/desktop/src/features/messages/ui/useThreadAgentAudience.ts b/desktop/src/features/messages/ui/useThreadAgentAudience.ts new file mode 100644 index 00000000000..e10dad9cf06 --- /dev/null +++ b/desktop/src/features/messages/ui/useThreadAgentAudience.ts @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { useKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; +import { + initializePersistentAgentAudience, + usePersistentAgentAudience, +} from "@/features/messages/lib/persistentAgentAudience"; + +export function useThreadAgentAudience({ + isAgentPubkey, + rootTags, + scope, +}: { + isAgentPubkey: (pubkey: string) => boolean; + rootTags: readonly string[][]; + scope: string | null; +}) { + const audience = usePersistentAgentAudience(scope); + const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); + + const rootAgentPubkeys = React.useMemo( + () => + rootTags.flatMap((tag) => { + const pubkey = tag[0] === "p" ? tag[1] : null; + return pubkey && isAgentPubkey(pubkey) ? [pubkey] : []; + }), + [isAgentPubkey, rootTags], + ); + + React.useEffect(() => { + if (!scope || !keepMentionedAgentsPinned) return; + initializePersistentAgentAudience(scope, rootAgentPubkeys); + }, [keepMentionedAgentsPinned, rootAgentPubkeys, scope]); + + return { audience, keepMentionedAgentsPinned }; +} diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx index 50538868d05..b2fde755ff2 100644 --- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx @@ -37,7 +37,7 @@ export function AgentsSettingsPanel() { className="mt-0.5 text-sm text-muted-foreground/70" data-settings-subcopy > - After you mention them once + Address selected agents in thread replies

readOutgoingMentionPubkeys(page, "@carl local")) .toEqual([managedPubkey]); - await expect(input).toHaveText("@carl "); + await expect(input).toHaveText(""); - await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); const reopenedDropdown = autocomplete(page); await expect(reopenedDropdown).toBeVisible(); const reopenedRelayRow = reopenedDropdown.getByTestId( `mention-suggestion-${relayPubkey}`, ); - await reopenedRelayRow - .getByRole("button", { name: "Automatically mention carl", exact: true }) - .click(); + await reopenedRelayRow.getByRole("button", { name: "Mention carl" }).click(); await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), ).toHaveCount(0); @@ -1441,7 +1436,7 @@ test("managed relay-profile agents with member roles can be addressed explicitly await expect(charlieRow.getByText("agent")).toBeVisible(); await charlieRow .getByRole("button", { - name: "Automatically mention charlie", + name: "Mention charlie", exact: true, }) .click(); @@ -1450,12 +1445,7 @@ test("managed relay-profile agents with member roles can be addressed explicitly await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), - ).toBeVisible(); - await expect( - page.getByRole("status").filter({ - hasText: "Automatically mentioning charlie", - }), - ).toBeVisible(); + ).toHaveCount(0); }); test("other-owned agents without a shared channel are hidden from mentions", async ({ @@ -2957,7 +2947,7 @@ test("a managed non-member agent from a DM can be addressed explicitly", async ( await expect(input.locator(".mention-chip")).toHaveCount(0); await charlieRow .getByRole("button", { - name: "Automatically mention charlie", + name: "Mention charlie", exact: true, }) .click(); @@ -2966,7 +2956,7 @@ test("a managed non-member agent from a DM can be addressed explicitly", async ( await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), - ).toBeVisible(); + ).toHaveCount(0); }); test("global non-member people can be selected from channel mentions", async ({ diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 6cc2f22a883..d1174132c74 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -49,6 +49,24 @@ async function automaticallyMention( await composer.locator("[data-mention-picker-trigger]").click(); } +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (currentChannelName) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function waitForTimelineSettled(page: Page) { + await expect(page.locator("[data-render-pending]")).toHaveCount(0); +} + async function openGeneral(page: Page) { await page.goto(`/#/channels/${CHANNEL_ID}`, { waitUntil: "domcontentloaded", @@ -92,6 +110,20 @@ async function pressPrimaryShiftM(page: Page) { await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); } +async function readPersistedDraftContent(page: Page, draftKey: string) { + return page.evaluate((key) => { + 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[key]; + if (draft) return draft.content ?? ""; + } + return ""; + }, draftKey); +} + async function readOutgoingMentionPubkeys(page: Page, content: string) { return page.evaluate((expectedContent) => { const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find( @@ -271,9 +303,9 @@ test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); await automaticallyMention(composer, "Vogue"); @@ -284,7 +316,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: "Manage mentions" }), ).toBeVisible(); }); @@ -292,21 +324,25 @@ test("keeps the composer and global automatic mention settings synchronized", as page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await composer.getByTestId("message-insert-mention").click(); - const optionsTrigger = composer.getByTestId("mention-options-trigger"); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Automatically mention Morgarita" }) - .click(); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); const composerToggle = composer.getByTestId( "mention-keep-agents-pinned-toggle", ); await expect(composerToggle).toHaveAttribute("data-state", "unchecked"); + const settings = composer.getByTestId("mention-options-settings"); + await expect(settings).toBeVisible(); + const settingsBox = await settings.boundingBox(); + const heading = settings.getByText("Automatically mention agents"); + const headingBox = await heading.boundingBox(); + expect(settingsBox?.width).toBeGreaterThanOrEqual(300); + expect(headingBox?.height).toBeLessThanOrEqual(20); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: "Automatically mention Morgarita" }) + .click(); await expect(composerToggle).toHaveAttribute("data-state", "checked", { timeout: 1_500, }); @@ -316,8 +352,7 @@ test("keeps the composer and global automatic mention settings synchronized", as .getByRole("button", { name: "Turn off" }) .click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); - await expect(composerToggle).toHaveAttribute("data-state", "checked"); + await expect(composer.getByTestId("mention-options-settings")).toBeVisible(); await expect(composerToggle).toHaveAttribute("data-state", "unchecked", { timeout: 1_500, }); @@ -336,7 +371,6 @@ test("keeps the composer and global automatic mention settings synchronized", as composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await composer.getByTestId("message-insert-mention").click(); - await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), ).toHaveAttribute("data-state", "unchecked"); @@ -347,7 +381,7 @@ test("keeps the composer and global automatic mention settings synchronized", as ).toHaveAttribute("aria-pressed", "false"); }); -test("hides automatic mention state while disabled without clearing the draft", async ({ +test("the disabled root composer preserves its explicit draft without audience controls", async ({ page, }) => { await installAudienceFixtures(page); @@ -355,11 +389,8 @@ test("hides automatic mention state while disabled without clearing the draft", const composer = channelComposer(page); const input = composer.getByTestId("message-input"); - await automaticallyMention(composer, "Morgarita"); - await input.type("draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + await input.fill("explicit draft text"); + await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0); await page.getByTestId("channel-management-trigger").click(); await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); @@ -368,22 +399,8 @@ test("hides automatic mention state while disabled without clearing the draft", await page.getByTestId("auxiliary-panel-close").click(); await expect(input).toHaveAttribute("contenteditable", "false"); - await expect(input).toHaveText("@Morgarita draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - - await page.getByTestId("channel-management-trigger").click(); - await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); - await page.getByTestId("channel-management-unarchive").click(); - await expect(page.getByTestId("channel-management-archive")).toBeVisible(); - await page.getByTestId("auxiliary-panel-close").click(); - - await expect(input).toHaveAttribute("contenteditable", "true"); - await expect(input).toHaveText("@Morgarita draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + await expect(input).toHaveText("explicit draft text"); + await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0); }); test("Tab inserts a one-time agent mention by default", async ({ page }) => { @@ -418,12 +435,11 @@ test("disabling automatic mentions leaves the composer empty after send", async }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId("message-insert-mention").click(); - await composer.getByTestId("mention-options-trigger").click(); const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); await expect(preference).toHaveAttribute("data-state", "checked"); await preference.click(); @@ -452,9 +468,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); await pressPrimaryShiftM(page); @@ -500,10 +516,10 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); - const input = channelComposer(page).getByTestId("message-input"); + const input = threadComposer(page).getByTestId("message-input"); await input.fill("draft text"); await input.press("ArrowLeft"); await input.press("ArrowLeft"); @@ -522,13 +538,13 @@ test("the mention button opens settings and can undo an address", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); const ingress = composer.getByRole("button", { - name: "Manage automatic agent mentions", + name: "Manage mentions", }); await input.type("draft text"); @@ -536,31 +552,34 @@ test("the mention button opens settings and can undo an address", async ({ const menu = composer.getByTestId("mention-autocomplete"); 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 layerBox = await composer .getByTestId("mention-autocomplete-layer") .boundingBox(); - const optionsBox = await page - .getByTestId("mention-options-trigger") + const settingsBox = await page + .getByTestId("mention-options-settings") .boundingBox(); + const menuBox = await menu.boundingBox(); expect(layerBox).not.toBeNull(); - expect(optionsBox).not.toBeNull(); - if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); - await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); + expect(settingsBox).not.toBeNull(); + expect(menuBox).not.toBeNull(); + if (!layerBox || !settingsBox || !menuBox) { + throw new Error("Mention tray is not laid out"); + } + await page.mouse.click( + layerBox.x + layerBox.width / 2, + (settingsBox.y + settingsBox.height + menuBox.y) / 2, + ); await expect(menu).toHaveCount(0); await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); - await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "false", - ); + await expect(page.getByTestId("mention-options-settings")).toBeVisible(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), - ).toHaveCount(0); + ).toBeVisible(); await ingress.click(); await expect(menu).toHaveCount(0); await expect(input).toHaveText("@Morgarita draft text"); @@ -569,16 +588,16 @@ test("the mention button opens settings and can undo an address", async ({ await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); await expect( menu.getByRole("button", { - name: "Don't automatically mention Morgarita in this conversation", + name: "Don't automatically mention Morgarita in this thread", }), ).toHaveAttribute("aria-pressed", "true"); await menu .getByRole("button", { - name: "Don't automatically mention Morgarita in this conversation", + name: "Don't automatically mention Morgarita in this thread", }) .click(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); @@ -656,7 +675,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: "Manage mentions" }), ).toBeVisible(); await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); @@ -690,7 +709,7 @@ test("always-mentioned agents remain selected without replaying their animation ).toHaveCount(1); }); -test("the unfocused main composer keeps its dismissed mention menu closed through a thread send", async ({ +test("the unfocused root composer keeps its dismissed mention menu closed through a thread send", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -698,12 +717,7 @@ test("the unfocused main composer keeps its dismissed mention menu closed throug const mainComposer = channelComposer(page); const mainInput = mainComposer.getByTestId("message-input"); - await automaticallyMention(mainComposer, "Morgarita"); - await mainInput.fill("@Morgarita earlier message"); - await mainInput.press("Enter"); - await expect(mainInput).toHaveText("@Morgarita "); - await mainInput.fill("@Morgarita"); - await expect(mainInput).toHaveText("@Morgarita"); + await mainInput.fill("@Mor"); await expect(mainComposer.getByTestId("mention-autocomplete")).toBeVisible(); await mainInput.press("Escape"); await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0); @@ -760,9 +774,9 @@ test("pressing a mention overlay's own container keeps it open", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId("message-insert-mention").click(); const list = composer.getByTestId("mention-autocomplete"); @@ -782,7 +796,6 @@ test("pressing a mention overlay's own container keeps it open", async ({ // Same hazard on the options surface, where it needs no exotic scrollbar // setting to reproduce: the switch's label text is a container press, so the // overlay used to vanish before the forwarded click reached the switch. - await composer.getByTestId("mention-options-trigger").click(); const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); await expect(preference).toHaveAttribute("data-state", "checked"); await composer @@ -793,14 +806,14 @@ test("pressing a mention overlay's own container keeps it open", async ({ await expect(list).toBeVisible(); }); -test("the mention Options controls are reachable and operable by keyboard", async ({ +test("the mention setting is reachable and operable by keyboard", async ({ page, }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openThread(page); - const mainComposer = channelComposer(page); + const mainComposer = threadComposer(page); const mainInput = mainComposer.getByTestId("message-input"); await mainInput.click(); await mainInput.fill("@Mor"); @@ -821,23 +834,15 @@ test("the mention Options controls are reachable and operable by keyboard", asyn }); // Forward Tab still selects the highlighted suggestion, so Shift+Tab is the - // route into the overlay. It only reaches the Options controls if the focus - // gate treats them as composer-owned focus rather than unmounting on the - // editor's blur. + // route into the always-visible setting. The focus gate must treat it as + // composer-owned focus rather than unmounting on the editor's blur. await mainInput.press("Shift+Tab"); - const optionsTrigger = mainComposer.getByTestId("mention-options-trigger"); - await expect(optionsTrigger).toBeFocused(); - - await page.keyboard.press("Enter"); const preference = mainComposer.getByTestId( "mention-keep-agents-pinned-toggle", ); - await expect(preference).toBeVisible(); + await expect(preference).toBeFocused(); await expect(preference).toHaveAttribute("data-state", "checked"); - // The switch sits before its trigger in the expanded surface's tab order. - await page.keyboard.press("Shift+Tab"); - await expect(preference).toBeFocused(); await page.keyboard.press("Space"); await expect(preference).toHaveAttribute("data-state", "unchecked"); @@ -866,9 +871,9 @@ test("the mention Options controls are reachable and operable by keyboard", asyn // under test. await mainInput.fill("@Mor"); await expect(list).toBeVisible(); - const threadInput = threadComposer(page).getByTestId("message-input"); - await threadInput.focus(); - await expect(threadInput).toBeFocused(); + const rootInput = channelComposer(page).getByTestId("message-input"); + await rootInput.focus(); + await expect(rootInput).toBeFocused(); await expect(list).toHaveCount(0); }); @@ -907,9 +912,9 @@ test("a manual mention persists when automatic mentions are enabled", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -928,7 +933,7 @@ test("a manual mention persists when automatic mentions are enabled", async ({ await expect(autoPinConfirmation).not.toContainText( "Future messages in this channel will include this agent.", ); - await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "left"); await expect(autoPinConfirmation.locator("span")).toHaveCSS( "white-space", "nowrap", @@ -948,8 +953,8 @@ test("a manual mention persists when automatic mentions are enabled", async ({ if (!addressControlBox || !confirmationBox) { throw new Error("Automatic mention confirmation is not laid out"); } - expect(confirmationBox.x).toBeGreaterThan( - addressControlBox.x + addressControlBox.width, + expect(confirmationBox.x + confirmationBox.width).toBeLessThanOrEqual( + addressControlBox.x, ); const turnOffAction = autoPinConfirmation.getByRole("button", { name: "Turn off", @@ -974,6 +979,9 @@ test("a manual mention persists when automatic mentions are enabled", async ({ .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveAttribute("contenteditable", "true", { + timeout: 2_500, + }); await input.fill("follow up"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), @@ -989,9 +997,9 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1010,10 +1018,7 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await expect(composer.getByTestId("mention-options-settings")).toBeVisible(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), ).toHaveAttribute("data-state", "unchecked"); @@ -1029,9 +1034,9 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ test("the auto-pin popover remains open while hovered", async ({ page }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1053,9 +1058,9 @@ test("removing the mention chip dismisses the auto-pin popover", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1076,31 +1081,23 @@ test("removing the mention chip dismisses the auto-pin popover", async ({ await expect(autoPinConfirmation).toHaveCount(0); }); -test("automatic mentions are scoped to their channel or thread composer", async ({ +test("automatic mentions exist only in thread composers and stay thread-scoped", async ({ page, }) => { await installAudienceFixtures(page); await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await openThread(page); + const rootComposer = channelComposer(page); + await rootComposer.getByTestId("message-insert-mention").click(); await expect( - threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + rootComposer.getByTestId("mention-options-settings"), ).toHaveCount(0); - - await automaticallyMention(threadComposer(page), "Vogue"); - await openGeneral(page); await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + rootComposer.getByRole("button", { name: /^Automatically mention / }), ).toHaveCount(0); await openThread(page); + await automaticallyMention(threadComposer(page), "Vogue"); await expect( threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); @@ -1111,72 +1108,119 @@ 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 root agent mention is explicit for one message and never becomes retained", async ({ page, }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { agentAName: "claude code" }); await openGeneral(page); - const rootComposer = channelComposer(page); - const rootInput = rootComposer.getByTestId("message-input"); - await automaticallyMention(rootComposer, "claude code"); - await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await rootComposer - .getByTestId(`composer-address-lock-remove-${AGENT_A}`) - .click(); - await expect(rootInput).toHaveText(""); - await expect( - 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(""); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + const firstRootMessage = "@claude code one time"; + await input.fill("@cla"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.pressSequentially(" one time"); + await expect(input).toHaveText(firstRootMessage); await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await input.press("Enter"); - await openThread(page); - const activeThreadComposer = threadComposer(page); - const threadInput = activeThreadComposer.getByTestId("message-input"); - await threadInput.fill("@cla"); - await expect( - activeThreadComposer.getByTestId("mention-autocomplete"), - ).toBeVisible(); - await threadInput.press("Tab"); - await expect( - activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await threadInput.type("thread message"); - await threadInput.press("Enter"); + await expect(input).toHaveText(""); + await expect + .poll(() => + page.evaluate( + (pubkey) => + Boolean( + window.__BUZZ_E2E_SIGNED_EVENTS__?.some((event) => + (event.tags ?? []).some( + (tag) => tag[0] === "p" && tag[1] === pubkey, + ), + ), + ), + AGENT_A, + ), + ) + .toBe(true); + await input.fill("next root message"); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "next root message")) + .toEqual([]); +}); +test("a removed root-inherited agent stays excluded after the thread reopens", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); await openGeneral(page); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + await waitForMockLiveSubscription(page, "general"); - const restoredRootInput = channelComposer(page).getByTestId("message-input"); - await restoredRootInput.fill("@cla"); + const rootId = "c".repeat(64); + const rootContent = "@Morgarita root request"; + await page.evaluate( + ({ agentPubkey, content, eventId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + id: eventId, + mentionPubkeys: [agentPubkey], + }); + }, + { agentPubkey: AGENT_A, content: rootContent, eventId: rootId }, + ); + await waitForTimelineSettled(page); + + const rootRow = page + .getByTestId("message-timeline") + .locator(`[data-testid="message-row"][data-message-id="${rootId}"]`); + await expect(rootRow).toBeVisible(); + await rootRow.hover(); + await rootRow.getByRole("button", { name: "Reply" }).click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + + let composer = threadComposer(page); await expect( - channelComposer(page).getByTestId("mention-autocomplete"), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - await restoredRootInput.press("Tab"); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - await restoredRootInput.type("one time"); - await restoredRootInput.press("Enter"); + await expect(composer.getByTestId("message-input")).toHaveText("@Morgarita "); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(composer.getByTestId("message-input")).toHaveText(""); + + const threadPanel = page.getByTestId("message-thread-panel"); + await threadPanel.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toBeHidden(); + + const loadedRootTags = await page.evaluate(async (eventId) => { + const raw = await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", { + eventId, + }); + return typeof raw === "string" + ? (JSON.parse(raw) as { tags?: string[][] }).tags + : null; + }, rootId); + expect(loadedRootTags).toContainEqual(["p", AGENT_A]); + + await rootRow.hover(); + await rootRow.getByRole("button", { name: "Reply" }).click(); + await expect(threadPanel).toBeVisible(); - await expect(restoredRootInput).toHaveText(""); + composer = threadComposer(page); + const input = composer.getByTestId("message-input"); await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(input).toHaveText(""); + + const reply = "plain reply after reopening"; + await input.fill(reply); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, reply)) + .not.toContain(AGENT_A); }); test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ @@ -1184,10 +1228,10 @@ test("an unchecked agent remains excluded while automatic mentions stay enabled" }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); await expect(input).toHaveText(""); @@ -1208,9 +1252,9 @@ test("re-adding a deleted automatic mention restores its automatic mention state }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1246,13 +1290,13 @@ test("implicit automatic mentions stay out of persisted drafts", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - const input = channelComposer(page).getByTestId("message-input"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); + const input = threadComposer(page).getByTestId("message-input"); await input.type("draft text"); - await openThread(page); await openGeneral(page); + await openThread(page); await expect(input).toHaveText("@Morgarita draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); @@ -1266,19 +1310,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(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("draft text continues"); }); @@ -1286,37 +1318,23 @@ test("an authored duplicate leading mention survives draft restoration", async ( page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - const input = channelComposer(page).getByTestId("message-input"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); + const input = threadComposer(page).getByTestId("message-input"); await input.pressSequentially("@Morgarita authored duplicate"); - await openThread(page); await openGeneral(page); + await openThread(page); - await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); - // Exact typed mentions now resolve on Space, so both the automatic prefix and - // the authored duplicate retain mention identity after restoration. - await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); 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(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita authored duplicate"); }); @@ -1324,8 +1342,8 @@ test("typed deletion preserves an identical authored mention in drafts", async ( page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1344,20 +1362,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(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita manual after typed deletion"); }); @@ -1365,8 +1370,8 @@ test("removing an automatic mention preserves an identical authored mention in d page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1377,20 +1382,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(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita manual after removal"); }); @@ -1398,35 +1390,22 @@ test("multiple automatic mentions stay out of persisted drafts", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); await automaticallyMention(composer, "Vogue"); await input.pressSequentially("draft text"); - await openThread(page); await openGeneral(page); - await expect(input).toHaveText("@Vogue @Morgarita draft text"); + await openThread(page); + await expect(input).toHaveText("@Morgarita @Vogue draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); 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), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("draft text"); }); @@ -1434,8 +1413,8 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1444,30 +1423,18 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra await automaticallyMention(composer, "Morgarita"); await input.pressSequentially("@Morgarita authored duplicate"); - await openThread(page); await openGeneral(page); + await openThread(page); - await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); - await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); 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(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita authored duplicate"); }); @@ -1475,8 +1442,8 @@ test("a restored multi-word automatic mention remains a chip with the caret afte page, }) => { await installAudienceFixtures(page, { agentAName: "claude code" }); - await openGeneral(page); - const originalComposer = channelComposer(page); + await openThread(page); + const originalComposer = threadComposer(page); await automaticallyMention(originalComposer, "claude code"); const originalInput = originalComposer.getByTestId("message-input"); await originalInput.pressSequentially("hello"); @@ -1490,9 +1457,9 @@ test("a restored multi-word automatic mention remains a chip with the caret afte waitUntil: "domcontentloaded", }); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); const expectedContent = "@claude code "; await expect(input).toHaveText(expectedContent); @@ -1501,7 +1468,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: "Manage mentions" }), ).toBeVisible(); await page.waitForTimeout(500); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); @@ -1524,9 +1491,9 @@ test("reduced motion removes addressed agents without spatial animation", async await page.emulateMedia({ reducedMotion: "reduce" }); await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1572,7 +1539,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: "Manage mentions" }), ).toBeVisible(); await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); @@ -1581,9 +1548,9 @@ test("the mention-button placement fits the narrow composer", async ({ test("captures the lightweight auto-pin popover", async ({ page }) => { await seedTheme(page, "buzz-dark"); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); await pressPrimaryShiftM(page); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index 3a7fdf7613a..7854658dd0c 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,12 +84,12 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the inline mention and persistent address are present before submitting. + // Verify the inline mention is present without creating thread-retained state. await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), - ).toBeVisible(); + ).toHaveCount(0); // Snapshot the baseline command count before sending const baselineCommands = await readCommandLog(page);