diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2ff..5398b28f055 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -8,6 +8,7 @@ import { getAgentMentionAdmission, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, isAgentMentionChannelType, relayAgentCanRespondInChannel, @@ -42,6 +43,15 @@ function makeAgent(overrides = {}) { }; } +test("isAgentDirectoryReady: requires successful cached directory evidence", () => { + assert.equal(isAgentDirectoryReady({ data: [], error: null }), true); + assert.equal(isAgentDirectoryReady({ data: undefined, error: null }), false); + assert.equal( + isAgentDirectoryReady({ data: [], error: new Error("offline") }), + false, + ); +}); + test("getSharedChannelIds: includes only active joined channels", () => { assert.deepEqual( getSharedChannelIds([ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92e..e3c82cfff4f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,19 @@ import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function isAgentDirectoryReady({ + data, + error, +}: { + data: unknown; + error: unknown; +}) { + // A successful cached directory remains suitable for autocomplete during a + // refetch. Sending still re-fetches and fails closed at its authorization + // boundary, so suggestions are hints rather than permission to send. + return data !== undefined && error === null; +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 562f890c0f5..9e15cdc6359 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -12,6 +12,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent"; @@ -184,12 +185,8 @@ export function MembersSidebar({ relayAgentsQuery, } = useClassifiedMembers(rawMembers, currentPubkey); const agentDirectoriesReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching && - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + isAgentDirectoryReady(managedAgentsQuery) && + isAgentDirectoryReady(relayAgentsQuery); const activeMembers = React.useMemo( () => [...people, ...bots].sort((left, right) => diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index b30008109c1..06a24b5b681 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -8,6 +8,7 @@ globalThis.localStorage = { }; const preference = await import("./autoPinMentionedAgentsPreference.ts"); +const persistentAudience = await import("./persistentAgentAudience.ts"); test("defaults missing and invalid values to one-time agent mentions", () => { assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); @@ -31,3 +32,16 @@ test("persists changes to the post-mention pinning preference", () => { "false", ); }); + +test("turning off automatic mentions clears active conversation audiences", () => { + const scope = `${"1".repeat(64)}:channel-a:channel`; + persistentAudience.setPersistentAgentAudience(scope, ["a".repeat(64)]); + preference.setKeepMentionedAgentsPinned(true); + + preference.setKeepMentionedAgentsPinned(false); + + assert.deepEqual( + persistentAudience.getPersistentAgentAudienceSnapshot().audiences, + {}, + ); +}); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 8f8e0b12d65..27a792f3353 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { resetPersistentAgentAudienceStore } from "./persistentAgentAudience"; + export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; @@ -37,6 +39,7 @@ export function getKeepMentionedAgentsPinned(): boolean { } export function setKeepMentionedAgentsPinned(value: boolean): void { + if (!value) resetPersistentAgentAudienceStore(); if (value === keepMentionedAgentsPinned) return; keepMentionedAgentsPinned = value; try { diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index afb2a289216..250e40e0a38 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -23,6 +23,7 @@ function input(overrides = {}) { managedAgents: [], memberPubkeys: new Set(), members: [], + mentionChannelId: null, mentionableAgentPubkeys: new Set(), personaNameByPubkey: new Map(), profiles: undefined, diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 1d9771317af..45bdf591e0c 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -36,6 +36,7 @@ export type BuildMentionCandidatesInput = { managedAgents: readonly ManagedAgent[] | undefined; memberPubkeys: ReadonlySet; members: readonly ChannelMember[] | undefined; + mentionChannelId: string | null; mentionableAgentPubkeys: ReadonlySet; personaNameByPubkey: ReadonlyMap; profiles: UserProfileLookup | undefined; @@ -66,6 +67,7 @@ export function buildMentionCandidates({ managedAgents, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -167,7 +169,13 @@ export function buildMentionCandidates({ kind: "identity", pubkey, displayName: agent.name, - isMember: false, + // Prefer the active channel's signed roster. The relay-agent directory + // is filtered by access policy, so its channel ids can legitimately omit + // a room where this identity is already a member. + isMember: + memberPubkeys.has(pubkey) || + (mentionChannelId !== null && + agent.channelIds.includes(mentionChannelId)), personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), @@ -177,11 +185,12 @@ export function buildMentionCandidates({ }); } for (const agent of managedAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); addCandidate({ kind: "identity", - pubkey: agent.pubkey, + pubkey, displayName: agent.name, - isMember: false, + isMember: memberPubkeys.has(pubkey), isAgent: true, isActiveAgent: agent.status === "running" || agent.status === "deployed", isManagedAgent: true, diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index e55c79c9a38..2d75bcacc52 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -431,7 +431,12 @@ export const MentionHighlightExtension = Extension.create({ applying = false; } } - setDomCaretAtPos(view, view.state.selection.from); + // Highlight refreshes can land after the user has moved focus to + // a popover. Keep settlement armed for the next keystroke, but + // never drag DOM selection back into an unfocused composer. + if (view.hasFocus()) { + setDomCaretAtPos(view, view.state.selection.from); + } }, destroy() { settlement.cancel(); diff --git a/desktop/src/features/messages/lib/mentionMemberPubkeys.ts b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts new file mode 100644 index 00000000000..ae1a3e73816 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts @@ -0,0 +1,19 @@ +import type { Channel, ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; + +/** Merge the dedicated roster with the active channel's signed projection. */ +export function getMentionMemberPubkeys( + channelId: string | null, + channels: readonly Channel[] | undefined, + members: ChannelMember[] | undefined, +): Set { + const pubkeys = new Set( + members ? channelMemberPubkeySet(members) : undefined, + ); + const activeChannel = channels?.find((channel) => channel.id === channelId); + for (const pubkey of activeChannel?.memberPubkeys ?? []) { + pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; +} diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index 64d11cd6312..dc3ca942416 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -134,20 +134,29 @@ test("reset clears every audience for refresh and community boundaries", async ( assert.deepEqual(currentAudiences(store), {}); }); -test("channel and thread composers share the channel audience scope", async () => { +test("channel and thread composers have independent audience scopes", async () => { const store = await loadStore(8); const channelScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", + composerKey: "channel-a", }); const threadScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", - threadRootId: "root", + composerKey: "thread:root", + }); + const otherThreadScope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + composerKey: "thread:other-root", }); assert.equal(channelScope, `${ownerA}:channel-a:channel`); - assert.equal(threadScope, channelScope); + assert.equal(threadScope, `${ownerA}:channel-a:thread:root`); + assert.equal(otherThreadScope, `${ownerA}:channel-a:thread:other-root`); + assert.notEqual(threadScope, channelScope); + assert.notEqual(otherThreadScope, threadScope); }); test("delayed promotion cannot overwrite a newer audience choice", async () => { @@ -191,6 +200,42 @@ test("stale auto-pin Undo cannot remove a newer explicit choice", async () => { assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); }); +test("explicitly excluded agents are not auto-promoted again", async () => { + const store = await loadStore(12); + const scope = `${ownerA}:channel-a:channel`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + pubkeys: [agentA], + scope, + }); + + assert.equal(promotion, null); + 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`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + reinstateExcluded: true, + pubkeys: [agentA], + scope, + }); + + assert.deepEqual(promotion?.promotedPubkeys, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + test("promotion reports only newly added agents for transactional Undo", async () => { const store = await loadStore(11); 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 018a7da489a..78819e0c735 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -4,6 +4,7 @@ export const MAX_IN_MEMORY_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); +const excludedPubkeysByScope = new Map>(); let revisionClock = 0; let defaultRevision = 0; let audiences: Record = {}; @@ -16,7 +17,7 @@ export type PersistentAgentAudienceSnapshot = Readonly<{ type PersistentAgentAudienceScopeInput = { ownerPubkey: string; channelId: string; - threadRootId?: string | null; + composerKey?: string | null; }; function normalizePubkeys(pubkeys: Iterable): string[] { @@ -46,17 +47,22 @@ function emit(): void { export function getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey, }: PersistentAgentAudienceScopeInput): string | null { const owner = ownerPubkey.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null; - // Thread composers intentionally share their parent channel's audience. - return `${owner}:${channelId}:channel`; + const composer = + composerKey?.trim() && composerKey.trim() !== channelId + ? composerKey.trim() + : "channel"; + return `${owner}:${channelId}:${composer}`; } export function resetPersistentAgentAudienceStore(): void { revisionClock += 1; defaultRevision = revisionClock; revisions.clear(); + excludedPubkeysByScope.clear(); audiences = {}; emit(); } @@ -83,6 +89,11 @@ export function setPersistentAgentAudience( const nextAudiences = { ...audiences }; delete nextAudiences[scope]; audiences = boundAudiences({ ...nextAudiences, [scope]: normalized }); + for (const excludedScope of excludedPubkeysByScope.keys()) { + if (!Object.hasOwn(audiences, excludedScope)) { + excludedPubkeysByScope.delete(excludedScope); + } + } for (const revisedScope of revisions.keys()) { if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); } @@ -97,19 +108,29 @@ export function getPersistentAgentAudienceRevision(scope: string): number { export function promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded = false, pubkeys, scope, }: { expectedRevision: number; + reinstateExcluded?: boolean; pubkeys: Iterable; scope: string; }): { promotedPubkeys: string[]; revision: number } | null { if (getPersistentAgentAudienceRevision(scope) !== expectedRevision) return null; - const promotedPubkeys = normalizePubkeys(pubkeys).filter( - (pubkey) => !(audiences[scope] ?? []).includes(pubkey), + const normalizedPubkeys = normalizePubkeys(pubkeys); + const promotedPubkeys = normalizedPubkeys.filter( + (pubkey) => + !(audiences[scope] ?? []).includes(pubkey) && + (reinstateExcluded || !excludedPubkeysByScope.get(scope)?.has(pubkey)), ); if (promotedPubkeys.length === 0) return null; + if (reinstateExcluded) { + const excluded = excludedPubkeysByScope.get(scope); + for (const pubkey of promotedPubkeys) excluded?.delete(pubkey); + if (excluded?.size === 0) excludedPubkeysByScope.delete(scope); + } setPersistentAgentAudience(scope, [ ...(audiences[scope] ?? []), ...promotedPubkeys, @@ -143,7 +164,22 @@ export function addPersistentAgentAudienceMember( scope: string, pubkey: string, ): void { - setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), pubkey]); + const normalized = normalizePubkeys([pubkey])[0]; + if (!normalized) return; + excludedPubkeysByScope.get(scope)?.delete(normalized); + setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), normalized]); +} + +export function excludePersistentAgentAudienceMember( + scope: string, + pubkey: string, +): void { + const normalized = normalizePubkeys([pubkey])[0]; + if (!scope || !normalized) return; + const excluded = excludedPubkeysByScope.get(scope) ?? new Set(); + excluded.add(normalized); + excludedPubkeysByScope.set(scope, excluded); + removePersistentAgentAudienceMember(scope, normalized); } export function removePersistentAgentAudienceMember( @@ -179,6 +215,7 @@ export function usePersistentAgentAudience(scope: string | null): { pubkeys: readonly string[]; addPubkey: (pubkey: string) => void; removePubkey: (pubkey: string) => void; + excludePubkey: (pubkey: string) => void; clear: () => void; } { const state = React.useSyncExternalStore( @@ -197,6 +234,10 @@ export function usePersistentAgentAudience(scope: string | null): { (pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey), [resolvedScope], ), + excludePubkey: React.useCallback( + (pubkey) => excludePersistentAgentAudienceMember(resolvedScope, pubkey), + [resolvedScope], + ), clear: React.useCallback( () => setPersistentAgentAudience(resolvedScope, []), [resolvedScope], diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs new file mode 100644 index 00000000000..d1c2a11cf01 --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { stripImplicitAgentMentionPrefix } from "./stripImplicitAgentMentions.ts"; + +test("removes the exact synthesized leading prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita draft text", "@Morgarita "), + "draft text", + ); +}); + +test("removes the complete captured prefix for multiple agents", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Vogue draft text", + "@Morgarita @Vogue ", + ), + "draft text", + ); +}); + +test("removes an implicit-only mention when markdown drops its separator", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita", "@Morgarita "), + "", + ); +}); + +test("preserves an identical authored mention after the synthesized prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Morgarita authored duplicate", + "@Morgarita ", + ), + "@Morgarita authored duplicate", + ); +}); + +test("preserves content when the captured prefix does not match exactly", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Alice ask @Morgarita", "@Morgarita "), + "@Alice ask @Morgarita", + ); +}); diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts new file mode 100644 index 00000000000..0ac80ca751e --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts @@ -0,0 +1,15 @@ +/** + * Removes the exact leading prefix synthesized by automatic agent addressing. + * The captured prefix is provenance: an identical authored mention immediately + * after it must remain draft content. + */ +export function stripImplicitAgentMentionPrefix( + content: string, + implicitPrefix: string, +): string { + if (!implicitPrefix) return content; + if (content.startsWith(implicitPrefix)) { + return content.slice(implicitPrefix.length); + } + return content === implicitPrefix.trimEnd() ? "" : content; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0d2e5ef6ac1..de6e6da6c8f 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -18,6 +18,7 @@ import { getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentMentionChannelType, rememberSelectedAgentPubkeys, uniqueAutocompleteLabels, @@ -32,7 +33,6 @@ import type { ChannelMember, ChannelType } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; @@ -50,6 +50,7 @@ import { } from "./useMentionSelection"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; +import { getMentionMemberPubkeys } from "./mentionMemberPubkeys"; import { appendUniqueName, buildTeamMentionCandidates, @@ -99,14 +100,8 @@ export function useMentions( const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); - const managedAgentDirectoryReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching; - const relayAgentDirectoryReady = - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + const managedAgentDirectoryReady = isAgentDirectoryReady(managedAgentsQuery); + const relayAgentDirectoryReady = isAgentDirectoryReady(relayAgentsQuery); const agentDirectoriesReady = managedAgentDirectoryReady && relayAgentDirectoryReady; const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; @@ -228,11 +223,9 @@ export function useMentions( () => new Set(activePersonas.map((persona) => persona.id)), [activePersonas], ); - // Identity-cached (shared with the timeline's roster derivations) — the - // Set is built once per distinct roster instead of per consumer. const memberPubkeys = React.useMemo( - () => (members ? channelMemberPubkeySet(members) : new Set()), - [members], + () => getMentionMemberPubkeys(channelId, channelsQuery.data, members), + [channelId, channelsQuery.data, members], ); const agentIdentityPubkeys = React.useMemo( () => @@ -260,6 +253,7 @@ export function useMentions( managedAgents: managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -283,6 +277,7 @@ export function useMentions( managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -535,11 +530,11 @@ export function useMentions( appendUniqueName(current, trimmedName), ); if (options?.isAgent) { - setSelectedAgentMentionNames((current) => { - const next = appendUniqueName(current, trimmedName); - selectedAgentMentionNamesRef.current = next; - return next; - }); + selectedAgentMentionNamesRef.current = appendUniqueName( + selectedAgentMentionNamesRef.current, + trimmedName, + ); + setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current); } }, [], diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index fa9644fa61b..e3e17071fad 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -74,6 +74,8 @@ export type AutocompleteEdit = { insertText: string; /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ preserveSelection?: boolean; + /** Skip asynchronous DOM caret reassertion when focus may move elsewhere. */ + reassertMentionCaret?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -162,6 +164,7 @@ export function useRichTextEditor({ onLinkSelectionChange, onLinkShortcut, }: RichTextEditorOptions) { + const addressedAgentMentionNamesRef = React.useRef([]); const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -648,11 +651,30 @@ export function useRichTextEditor({ syncMentionHighlightFromProps( editor, mentionNames, - agentMentionNames, + [ + ...new Set([ + ...(agentMentionNames ?? []), + ...addressedAgentMentionNamesRef.current, + ]), + ], channelNames, ); }, [editor, mentionNames, agentMentionNames, channelNames]); + const syncAddressedAgentMentionNames = React.useCallback( + (names: readonly string[]) => { + addressedAgentMentionNamesRef.current = names; + if (!editor) return; + syncMentionHighlightFromProps( + editor, + mentionNames, + [...new Set([...(agentMentionNames ?? []), ...names])], + channelNames, + ); + }, + [agentMentionNames, channelNames, editor, mentionNames], + ); + // Custom-emoji set changes: re-resolve the `src` attr on any existing // node in the doc (e.g. an emoji's image was just published). React.useEffect(() => { @@ -777,6 +799,7 @@ export function useRichTextEditor({ text: string, customEmojiShortcode?: string, preserveSelection = false, + reassertMentionCaret = !preserveSelection, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -825,7 +848,7 @@ export function useRichTextEditor({ settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); editor.view.focus(); - if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); + if (reassertMentionCaret) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); @@ -917,6 +940,7 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, + syncAddressedAgentMentionNames, getLinkSelectionInfo, applyLink, removeLink, diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 4fe40da4b37..5dfc851bfd2 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -111,10 +111,16 @@ test("mention control expands with automatically mentioned agents", async () => ); } const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); + const removeChrome = remove.querySelector("span.absolute"); assert.match( - remove.querySelector("span.absolute")?.className ?? "", + removeChrome?.className ?? "", /group-hover\/address:opacity-100/, ); + assert.match(removeChrome?.className ?? "", /(?:^|\s)bg-foreground(?:\s|$)/); + assert.doesNotMatch( + removeChrome?.className ?? "", + /(?:^|\s)bg-foreground\/80(?:\s|$)/, + ); fireEvent.click(remove); assert.deepEqual(removed, ["agent-pubkey"]); view.rerender(renderButton([])); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index f141a4d2fdc..5ebcc436581 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -129,6 +129,7 @@ export function ComposerMentionButton({ confirmationTitle, disabled, onConfirmationDismiss, + onConfirmationHoverChange, onConfirmationTurnOff, onCaptureSelection, onOpen, @@ -140,6 +141,7 @@ export function ComposerMentionButton({ confirmationTitle?: string | null; disabled: boolean; onConfirmationDismiss?: () => void; + onConfirmationHoverChange?: (hovered: boolean) => void; onConfirmationTurnOff?: () => void; onCaptureSelection: () => void; onOpen: () => void; @@ -216,11 +218,11 @@ export function ComposerMentionButton({ className="flex items-center gap-1 overflow-hidden" data-testid="composer-address-locks" exit={{ opacity: 0, width: 0 }} - initial={false} + initial={shouldReduceMotion ? false : { opacity: 0, width: 0 }} transition={ shouldReduceMotion ? { duration: 0 } - : { duration: 0.18, ease: "easeOut" } + : { duration: 0.12, ease: "easeOut" } } > @@ -228,7 +230,7 @@ export function ComposerMentionButton({ - + - Stop automatically mentioning {agent.displayName} + Don't automatically mention {agent.displayName} in this + conversation ))} @@ -289,6 +292,8 @@ export function ComposerMentionButton({ data-testid="composer-auto-pin-confirmation" onCloseAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} + onPointerEnter={() => onConfirmationHoverChange?.(true)} + onPointerLeave={() => onConfirmationHoverChange?.(false)} side="right" sideOffset={8} style={{ width: "max-content" }} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index 012c962e0f0..be3ec6938ef 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -77,6 +77,11 @@ test("agent rows offer automatic mention controls", async () => { }); assert.equal(action.getAttribute("aria-pressed"), "false"); assert.equal(action.getAttribute("data-state"), "off"); + const inactivePin = action.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.match(inactivePin?.getAttribute("class") ?? "", /\blucide-pin\b/); + assert.equal(inactivePin?.getAttribute("fill"), "none"); fireEvent.click(action); assert.deepEqual(toggled, [suggestion]); assert.deepEqual(selected, [suggestion]); @@ -88,10 +93,14 @@ test("agent rows offer automatic mention controls", async () => { }), ); const selectedAction = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", + name: "Don't automatically mention Agent Ada in this conversation", }); assert.equal(selectedAction.getAttribute("aria-pressed"), "true"); assert.equal(selectedAction.getAttribute("data-state"), "on"); + const activePin = selectedAction.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.equal(activePin?.getAttribute("fill"), "currentColor"); fireEvent.click(selectedAction); assert.deepEqual(toggled, [suggestion, suggestion]); }); @@ -168,6 +177,56 @@ test("options expand in place without replacing the people list", async () => { assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); }); +test("automatic selection loads the setting once, then updates it in place", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const props = { + suggestions: [suggestion], + selectedIndex: 0, + onSelect: () => {}, + keepMentionedAgentsPinned: false, + onKeepMentionedAgentsPinnedChange: () => {}, + }; + const view = render( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 0, + }), + ); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 1, + }), + ); + assert.equal( + view.getByRole("button", { name: "Options" }).getAttribute("aria-expanded"), + "true", + ); + const toggle = view.getByRole("switch", { + name: "Automatically mention agents", + }); + const settings = view.getByTestId("mention-options-settings"); + assert.equal(toggle.getAttribute("data-state"), "unchecked"); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + keepMentionedAgentsPinned: true, + openOptionsRequest: 2, + }), + ); + assert.equal(view.getByTestId("mention-options-settings"), settings); + assert.equal(toggle.getAttribute("data-state"), "checked"); +}); + test("clicking outside dismisses the tray without intercepting its trigger", async () => { const React = await import("react"); const { fireEvent, render } = await import("@testing-library/react"); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 9287c4f0e13..a242898c2e6 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { AtSign, Bot, ChevronRight, Users } from "lucide-react"; +import { Bot, ChevronRight, Pin, Users } from "lucide-react"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { motion } from "motion/react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; @@ -44,6 +44,7 @@ type MentionAutocompleteProps = { keepMentionedAgentsPinned?: boolean; onKeepMentionedAgentsPinnedChange?: (value: boolean) => void; openOptionsRequest?: number; + onOptionsRevealComplete?: (request: number) => void; onDismiss?: () => void; position?: "above" | "below"; }; @@ -65,6 +66,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ keepMentionedAgentsPinned = true, onKeepMentionedAgentsPinnedChange, openOptionsRequest = 0, + onOptionsRevealComplete, onDismiss, position = "above", }: MentionAutocompleteProps) { @@ -74,6 +76,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ const optionsId = React.useId(); const keepPinnedSwitchId = React.useId(); const [optionsOpen, setOptionsOpen] = React.useState(false); + const handledOptionsRequestRef = React.useRef(0); const alwaysAddressShortcut = getPlatformKeysById("always-address-agent"); React.useEffect(() => { @@ -90,10 +93,17 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ }, [suggestions.length]); React.useEffect(() => { - if (openOptionsRequest > 0) { - setOptionsOpen(true); + if (openOptionsRequest <= handledOptionsRequestRef.current) return; + handledOptionsRequestRef.current = openOptionsRequest; + + // The first request waits for the entrance to finish. Once visible, apply + // later requests in place so toggling a pin cannot replay that entrance. + if (optionsOpen) { + onOptionsRevealComplete?.(openOptionsRequest); + return; } - }, [openOptionsRequest]); + setOptionsOpen(true); + }, [onOptionsRevealComplete, openOptionsRequest, optionsOpen]); React.useEffect(() => { if (!onDismiss) return; @@ -173,8 +183,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ { + if (openOptionsRequest > 0) { + onOptionsRevealComplete?.(openOptionsRequest); + } + }} transition={{ duration: 0.16, ease: "easeOut" }} >
@@ -390,7 +406,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ - @@ -416,11 +437,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ > {isAlwaysAddressed - ? "Stop automatically mentioning" + ? "Don't automatically mention in this conversation" : "Automatically mention"} {alwaysAddressShortcut ? ( - + {(alwaysAddressShortcut.includes("+") ? alwaysAddressShortcut.split("+") : Array.from(alwaysAddressShortcut) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 5c0dced5ca3..0cc8650795e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -64,9 +64,11 @@ import { useComposerAttachmentSpoilers } from "./useComposerAttachmentSpoilers"; import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -131,10 +133,13 @@ function MessageComposerImpl({ ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey: effectiveDraftKey, }) : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; + const implicitAgentMentionProvenance = + useImplicitAgentMentionProvenance(effectiveDraftKey); const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; @@ -204,6 +209,7 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix: implicitAgentMentionProvenance.getPrefix, }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { @@ -310,10 +316,13 @@ function MessageComposerImpl({ const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); const addressPulse = useAddressMentionPulse(); const { + completeOptionsReveal: completeMentionOptionsReveal, confirmationTitle: autoPinConfirmationTitle, dismissConfirmation: dismissAutoPinConfirmation, + openOptionsRequest: openMentionOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered: setAutoPinConfirmationHovered, turnOffConfirmation: turnOffAutoPinConfirmation, } = useAutoPinMentionedAgents({ audienceScope, @@ -321,26 +330,13 @@ function MessageComposerImpl({ getDisplayName: mentions.getMentionDisplayName, onPulse: addressPulse.pulseOne, onTurnOff: () => setKeepMentionedAgentsPinned(false), + onTurnOn: () => setKeepMentionedAgentsPinned(true), + }); + const addressedMentionRestore = useAddressedAgentMentionRestore({ + audiencePubkeys: persistentAudience.pubkeys, + channelId, + enabled: keepMentionedAgentsPinned, }); - const restoreAddressedAgentMentionsRef = React.useRef< - ( - pubkeys?: readonly string[], - allowedUnpinnedPubkeys?: readonly string[], - ) => string - >(() => ""); - const restoreAddressedAgentMentionsFrameRef = React.useRef( - null, - ); - const channelIdRef = React.useRef(channelId); - channelIdRef.current = channelId; - React.useEffect( - () => () => { - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - }, - [], - ); const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -350,23 +346,11 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, - onAddressedAgentsComposerCleared: (pubkeys) => - restoreAddressedAgentMentionsRef.current(pubkeys), + onAddressedAgentsComposerCleared: + addressedMentionRestore.onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed: addressPulse.shakeMany, - onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame( - () => { - restoreAddressedAgentMentionsFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys); - }, - ); - }, + onAddressedAgentsSendSucceeded: + addressedMentionRestore.onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText, @@ -445,6 +429,7 @@ function MessageComposerImpl({ edit.insertText, edit.customEmojiShortcode, edit.preserveSelection, + edit.reassertMentionCaret, ); }, [richText.replacePlainTextRange], @@ -467,16 +452,24 @@ function MessageComposerImpl({ promoteExplicitlyAddressedAgents({ pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }), - onAutoPinAgentMention: (suggestion) => { + onAutoPinAgentMention: (suggestion, options) => { promoteMentionedAgents({ + ...options, pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }); }, + onImplicitPrefixInserted: implicitAgentMentionProvenance.add, + onImplicitPrefixRemoved: implicitAgentMentionProvenance.remove, onPulseAddressLock: addressPulse.pulseOne, profiles, richText, }); - restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; + addressedMentionRestore.restoreAddressedAgentMentionsRef.current = + restoreAddressedAgentMentions; + React.useLayoutEffect(() => { + if (!audienceScope || editTarget != null) return; + restoreAddressedAgentMentions(); + }, [audienceScope, editTarget, restoreAddressedAgentMentions]); syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { @@ -536,21 +529,17 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - const openMentionPicker = useComposerMentionPicker({ + const mentionPicker = useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation: turnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }); - const openMentionSettings = React.useCallback( - () => openMentionPicker(false), - [openMentionPicker], - ); const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ enabled: Boolean(audienceScope && editTarget == null), lockedAgent: lockedAgents[0], mentions, - onOpenPicker: openMentionPicker, - onSelect: selectMentionSuggestion, + onOpenPicker: mentionPicker.openMentionPicker, onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { @@ -872,11 +861,13 @@ function MessageComposerImpl({
void; onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationHoverChange?: (hovered: boolean) => void; onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; @@ -187,6 +189,7 @@ export const MessageComposerToolbar = React.memo( confirmationTitle={autoPinConfirmationTitle} disabled={composerDisabled} onConfirmationDismiss={onAutoPinConfirmationDismiss} + onConfirmationHoverChange={onAutoPinConfirmationHoverChange} onConfirmationTurnOff={onAutoPinConfirmationTurnOff} onCaptureSelection={onCaptureSelection} onOpen={onOpenMentionPicker} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index b95528dd939..b0272500572 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,12 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection in place", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -78,7 +77,6 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" suggestions: [suggestion], }, onOpenPicker: () => {}, - onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, @@ -86,16 +84,13 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion, suggestion]); }); test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { @@ -126,7 +121,6 @@ test("primary+Shift+M removes the current locked agent before choosing a new def suggestions: [], }, onOpenPicker: () => {}, - onSelect: () => {}, onToggle: (value) => toggled.push(value), }), ); @@ -137,7 +131,7 @@ test("primary+Shift+M removes the current locked agent before choosing a new def altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -169,7 +163,6 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () onOpenPicker: () => { opened += 1; }, - onSelect: () => {}, onToggle: () => {}, }), ); @@ -180,7 +173,7 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts new file mode 100644 index 00000000000..9bc88ea6b47 --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts @@ -0,0 +1,66 @@ +import * as React from "react"; + +type RestoreAddressedAgentMentions = ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys?: readonly string[], +) => string; + +export function useAddressedAgentMentionRestore({ + audiencePubkeys, + channelId, + enabled, +}: { + audiencePubkeys: readonly string[]; + channelId: string | null; + enabled: boolean; +}) { + const restoreAddressedAgentMentionsRef = + React.useRef(() => ""); + const restoreFrameRef = React.useRef(null); + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; + + React.useEffect( + () => () => { + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + }, + [], + ); + + const onAddressedAgentsComposerCleared = React.useCallback( + (pubkeys: readonly string[]) => + restoreAddressedAgentMentionsRef.current(pubkeys), + [], + ); + const onAddressedAgentsSendSucceeded = React.useCallback( + (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { + const currentAudience = new Set(audiencePubkeys); + const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) => + currentAudience.has(pubkey), + ); + if (!enabled || confirmedPinnedPubkeys.length === 0) return; + + const sentChannelId = channelId; + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + restoreFrameRef.current = requestAnimationFrame(() => { + restoreFrameRef.current = null; + if (channelIdRef.current !== sentChannelId) return; + restoreAddressedAgentMentionsRef.current( + pubkeys, + confirmedPinnedPubkeys, + ); + }); + }, + [audiencePubkeys, channelId, enabled], + ); + + return { + onAddressedAgentsComposerCleared, + onAddressedAgentsSendSucceeded, + restoreAddressedAgentMentionsRef, + }; +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 95e0af9e06b..8ed155234fa 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -30,6 +30,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds ); const appliedEdits = []; const addedPubkeys = []; + const openPickerCalls = []; const pulsedPubkeys = []; let cancelCount = 0; const text = "@"; @@ -47,6 +48,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds getMentionDisplayName: () => "Agent Ada", isInlineMentionSelection: () => false, isMentionOpen: true, + openMentionPicker: (...args) => openPickerCalls.push(args), registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -82,9 +84,11 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds replaceToOffset: 0, insertText: "@Agent Ada ", preserveSelection: true, + reassertMentionCaret: false, }, ]); assert.equal(cancelCount, 0); + assert.deepEqual(openPickerCalls, [[text.length, "preserve"]]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -301,8 +305,8 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn audience, audienceScope: "channel-scope", mentions, - onAutoPinAgentMention: (suggestion) => - autoPinnedSuggestions.push(suggestion), + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -322,7 +326,9 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn insertText: "@Agent Ada ", }, ]); - assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(autoPinnedSuggestions, [ + [suggestion, { reinstateExcluded: true }], + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, []); assert.equal(result.current.announcement, ""); @@ -375,11 +381,149 @@ test("selecting a human mention never changes automatic addressing", async () => assert.deepEqual(autoPinnedSuggestions, []); }); -test("removing the last agent chip clears its automatic address", async () => { +test("restoring a multi-word automatic mention into an empty composer focuses after its trailing space", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@claude code ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 1); +}); + +test("restoring before authored text preserves its selection", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Morgarita", + registerMentionPubkey: () => {}, + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "draft text", cursor: 10 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Morgarita ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 0); +}); + +test("restoring an existing automatic mention re-registers its agent chip", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const syncedAddressedNames = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ + text: "@claude code ", + cursor: 13, + }), + syncAddressedAgentMentionNames: (names) => + syncedAddressedNames.push(names), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, []); + assert.equal(focusEndCount, 0); + assert.deepEqual(syncedAddressedNames.at(-1), ["claude code"]); +}); + +test("deleting the last automatic agent mention explicitly excludes its address", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const excludedPubkeys = []; const removedPubkeys = []; const mentionRefsByText = { "@Agent Ada first @Agent Ada second": [ @@ -396,6 +540,7 @@ test("removing the last agent chip clears its automatic address", async () => { applyAutocompleteEdit: () => {}, audience: { pubkeys: ["agent-pubkey", "existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -418,20 +563,23 @@ test("removing the last agent chip clears its automatic address", async () => { assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["agent-pubkey"]); + assert.deepEqual(excludedPubkeys, ["agent-pubkey"]); + assert.deepEqual(removedPubkeys, []); }); -test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { +test("deleting human mentions is ignored while deleting a restored automatic agent mention excludes its address", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const excludedPubkeys = []; const removedPubkeys = []; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: () => {}, audience: { pubkeys: ["existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -462,9 +610,11 @@ test("removing human mentions is ignored while removing a restored agent chip cl result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), ); act(() => result.current.syncAddressedAgentsFromText("@Alice")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); }); test("selecting an agent from the explicit picker auto-addresses it", async () => { @@ -529,13 +679,14 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = ); }); -test("selecting an explicitly unpinned agent inserts a mention until send", async () => { +test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); const appliedEdits = []; const addedPubkeys = []; + const autoPinnedSuggestions = []; const removedPubkeys = []; const pulsedPubkeys = []; const mentions = { @@ -545,7 +696,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ], getMentionDisplayName: () => "Agent Ada", registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, + isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 0, @@ -570,6 +721,8 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn }, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -577,7 +730,14 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 11, + insertText: "", + }, + ]); + appliedEdits.length = 0; rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ @@ -586,6 +746,13 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn isAgent: true, }); }); + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(appliedEdits, [ @@ -594,11 +761,77 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn replaceToOffset: 0, insertText: "@Agent Ada ", }, + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + }, ]); assert.deepEqual(addedPubkeys, []); + assert.deepEqual(autoPinnedSuggestions, [ + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + ]); assert.deepEqual(pulsedPubkeys, []); }); +test("restoring after an agent rename keeps the existing automatic mention", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const oldName = "OldName"; + const newName = "NewName"; + const { result, rerender } = renderHook( + ({ displayName }) => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: ["agent-pubkey"], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [ + { displayName: oldName, pubkey: "agent-pubkey", isAgent: true }, + ], + getMentionDisplayName: () => displayName, + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + profiles: {}, + richText: { + getPlainTextAndCursor: () => ({ + text: `@${oldName} authored draft`, + cursor: 23, + }), + }, + }), + { initialProps: { displayName: oldName } }, + ); + + rerender({ displayName: newName }); + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, []); + assert.deepEqual(registeredMentions, [ + [newName, "agent-pubkey", { isAgent: true }], + ]); +}); + test("an addressed agent keeps its resolved name while mention state clears during send", async () => { const { renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 17883900ecb..4a9ca5f15c1 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { getMentionOffsets } from "@/features/messages/lib/hasMention"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { @@ -59,6 +60,8 @@ export function useAgentAddressLockPicker({ mentions, onAddressAgentMention, onAutoPinAgentMention, + onImplicitPrefixInserted, + onImplicitPrefixRemoved, onPulseAddressLock, profiles, richText, @@ -68,7 +71,16 @@ export function useAgentAddressLockPicker({ audienceScope: string | null; mentions: UseMentionsResult; onAddressAgentMention?: (suggestion: MentionSuggestion) => void; - onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: ( + suggestion: MentionSuggestion, + options: { reinstateExcluded: boolean }, + ) => void; + /** Records generated mention provenance at the insertion boundary. */ + onImplicitPrefixInserted?: ( + mentions: readonly { pubkey: string; prefix: string }[], + ) => void; + /** Removes generated mention provenance by its stable identity. */ + onImplicitPrefixRemoved?: (pubkey: string) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -83,6 +95,11 @@ export function useAgentAddressLockPicker({ unpinnedAudienceScopeRef.current = audienceScope; unpinnedAgentPubkeysRef.current.clear(); } + React.useEffect(() => { + for (const pubkey of lockedAgentPubkeys) { + unpinnedAgentPubkeysRef.current.delete(pubkey); + } + }, [lockedAgentPubkeys]); const lockedAgentNamesRef = React.useRef(new Map()); const visibleAgentMentionPubkeysRef = React.useRef(new Set()); const mentionSyncScopeRef = React.useRef(audienceScope); @@ -115,6 +132,11 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); + React.useLayoutEffect(() => { + richText.syncAddressedAgentMentionNames?.( + lockedAgents.map((agent) => agent.displayName), + ); + }, [lockedAgents, richText.syncAddressedAgentMentionNames]); const trackMentionAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -138,16 +160,20 @@ export function useAgentAddressLockPicker({ !presentAgentPubkeys.has(pubkey) && lockedAgentPubkeys.has(pubkey) ) { - audience.removePubkey(pubkey); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + onImplicitPrefixRemoved?.(pubkey); + excludePubkey(pubkey); } } visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ + audience.excludePubkey, audience.removePubkey, audienceScope, lockedAgentPubkeys, mentions.getDraftMentionRefs, + onImplicitPrefixRemoved, ], ); @@ -156,9 +182,37 @@ export function useAgentAddressLockPicker({ const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; unpinnedAgentPubkeysRef.current.add(normalized); - audience.removePubkey(normalized); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + excludePubkey(normalized); + const displayName = lockedAgents.find( + (agent) => agent.pubkey === normalized, + )?.displayName; + if (displayName) { + const text = richText.getPlainTextAndCursor().text; + const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`; + const strippedText = stripImplicitAgentMentionPrefix( + text, + implicitPrefix, + ); + if (strippedText !== text) { + onImplicitPrefixRemoved?.(normalized); + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: text.length - strippedText.length, + insertText: "", + }); + } + } }, - [audience.removePubkey, audienceScope], + [ + applyAutocompleteEdit, + audience.excludePubkey, + audience.removePubkey, + audienceScope, + lockedAgents, + onImplicitPrefixRemoved, + richText.getPlainTextAndCursor, + ], ); const removeAddressedAgentMentions = React.useCallback( (pubkey: string) => { @@ -199,11 +253,14 @@ export function useAgentAddressLockPicker({ }); const { text } = richText.getPlainTextAndCursor(); if (getMentionOffsets(text, suggestion.displayName).length === 0) { + const insertedText = `@${suggestion.displayName} `; + onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, - insertText: `@${suggestion.displayName} `, - preserveSelection: true, + insertText: insertedText, + preserveSelection: text.length > 0, + reassertMentionCaret: false, }); } trackMentionAddressedAgent(pubkey); @@ -216,24 +273,31 @@ export function useAgentAddressLockPicker({ setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { + if (mentions.isMentionOpen) { const { text, cursor } = richText.getPlainTextAndCursor(); - const activeMention = detectPrefixQuery("@", text, cursor, [ - suggestion.displayName.toLowerCase(), - ]); - const queryStart = Math.max( - 0, - Math.min( - activeMention?.startIndex ?? mentions.mentionStartIndex, - text.length, - ), - ); - applyAutocompleteEdit({ - replaceFromOffset: queryStart, - replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)), - insertText: "", - }); - mentions.openMentionPicker(queryStart, "preserve"); + if (mentions.isInlineMentionSelection()) { + const activeMention = detectPrefixQuery("@", text, cursor, [ + suggestion.displayName.toLowerCase(), + ]); + const queryStart = Math.max( + 0, + Math.min( + activeMention?.startIndex ?? mentions.mentionStartIndex, + text.length, + ), + ); + applyAutocompleteEdit({ + replaceFromOffset: queryStart, + replaceToOffset: Math.max( + queryStart, + Math.min(cursor, text.length), + ), + insertText: "", + }); + mentions.openMentionPicker(queryStart, "preserve"); + } else { + mentions.openMentionPicker(cursor, "preserve"); + } } }, [ @@ -247,6 +311,7 @@ export function useAgentAddressLockPicker({ mentions.openMentionPicker, mentions.registerMentionPubkey, onAddressAgentMention, + onImplicitPrefixInserted, onPulseAddressLock, removeAddressedAgentMentions, richText.getPlainTextAndCursor, @@ -264,9 +329,10 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion); + onAutoPinAgentMention?.(suggestion, { + reinstateExcluded: !wasUnpinned, + }); return; } @@ -335,8 +401,24 @@ export function useAgentAddressLockPicker({ return { pubkey, displayName }; }); const { text } = richText.getPlainTextAndCursor(); + // A profile can rename an agent while this draft is off-screen. Mention + // refs retain the identity of its already-inserted automatic prefix, so + // use that identity as well as the current display name when deciding + // whether restoration is needed. + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); for (const agent of targetAgents) { - if (getMentionOffsets(text, agent.displayName).length > 0) { + if ( + presentAgentPubkeys.has(agent.pubkey) || + getMentionOffsets(text, agent.displayName).length > 0 + ) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); visibleAgentMentionPubkeysRef.current.add(agent.pubkey); } } @@ -344,6 +426,7 @@ export function useAgentAddressLockPicker({ (agent) => (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || allowedUnpinned.has(agent.pubkey)) && + !presentAgentPubkeys.has(agent.pubkey) && getMentionOffsets(text, agent.displayName).length === 0, ); if (missingAgents.length === 0) return text; @@ -356,20 +439,34 @@ export function useAgentAddressLockPicker({ const insertedText = `${missingAgents .map((agent) => `@${agent.displayName}`) .join(" ")} `; + onImplicitPrefixInserted?.( + missingAgents.map((agent) => ({ + pubkey: agent.pubkey, + prefix: `@${agent.displayName} `, + })), + ); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, insertText: insertedText, preserveSelection: true, }); + // A restored empty composer has no authored caret to preserve. Move it + // to the real document end after insertion so WebKit places it after + // the trailing space, without routing the multi-word name back through + // autocomplete settlement (which would lose its mention decoration). + if (text.length === 0) richText.focusEnd(); return `${insertedText}${text}`; }, [ applyAutocompleteEdit, audience.pubkeys, + mentions.getDraftMentionRefs, mentions.getMentionDisplayName, mentions.registerMentionPubkey, + onImplicitPrefixInserted, profiles, + richText.focusEnd, richText.getPlainTextAndCursor, ], ); diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index ed92e7b3f0f..cccb1b40869 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -9,14 +9,12 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentions, onOpenPicker, - onSelect, onToggle, }: { enabled: boolean; lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; - onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { const { @@ -48,11 +46,7 @@ export function useAlwaysAddressShortcut({ if (!isMentionOpen) onOpenPicker(false); return true; } - if (isMentionOpen) { - onSelect(suggestion); - } else { - onToggle(suggestion); - } + onToggle(suggestion); return true; }, [ @@ -62,7 +56,6 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentionSelectedIndex, onOpenPicker, - onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index d0557567fdd..550cdef9832 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -4,6 +4,7 @@ import { getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, + usePersistentAgentAudience, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -16,12 +17,19 @@ type Confirmation = { title: string; }; +type PendingPreferenceChange = { + confirmation?: Confirmation; + enabled: boolean; + request: number; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; onPulse: (pubkey: string) => void; onTurnOff: () => void; + onTurnOn: () => void; }; export function useAutoPinMentionedAgents({ @@ -30,19 +38,101 @@ export function useAutoPinMentionedAgents({ getDisplayName, onPulse, onTurnOff, + onTurnOn, }: Options) { + const { pubkeys: currentAudiencePubkeys } = + usePersistentAgentAudience(audienceScope); const [confirmation, setConfirmation] = React.useState( null, ); + const [confirmationHovered, setConfirmationHovered] = React.useState(false); + const [openOptionsRequest, setOpenOptionsRequest] = React.useState(0); + const nextOptionsRequestRef = React.useRef(0); + const pendingPreferenceChangeRef = + React.useRef(null); + const onTurnOffRef = React.useRef(onTurnOff); + const onTurnOnRef = React.useRef(onTurnOn); + onTurnOffRef.current = onTurnOff; + onTurnOnRef.current = onTurnOn; React.useEffect(() => { - if (!confirmation) return; + if (pendingPreferenceChangeRef.current?.enabled === enabled) { + pendingPreferenceChangeRef.current = null; + } + }, [enabled]); + + React.useEffect( + () => () => { + const pending = pendingPreferenceChangeRef.current; + pendingPreferenceChangeRef.current = null; + if (!pending) return; + if (pending.enabled) { + onTurnOnRef.current(); + } else { + onTurnOffRef.current(); + } + }, + [], + ); + + const requestPreferenceChange = React.useCallback( + (preferenceEnabled: boolean, pendingConfirmation?: Confirmation) => { + const request = nextOptionsRequestRef.current + 1; + nextOptionsRequestRef.current = request; + pendingPreferenceChangeRef.current = { + confirmation: pendingConfirmation, + enabled: preferenceEnabled, + request, + }; + setOpenOptionsRequest(request); + }, + [], + ); + + const completeOptionsReveal = React.useCallback((request: number) => { + const pending = pendingPreferenceChangeRef.current; + if (!pending || pending.request !== request) return; + pendingPreferenceChangeRef.current = null; + if (pending.enabled) { + onTurnOnRef.current(); + return; + } + if (pending.confirmation) { + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: pending.confirmation.expectedRevision, + pubkeys: pending.confirmation.pubkeys, + scope: pending.confirmation.scope, + }); + } + onTurnOffRef.current(); + }, []); + + const clearConfirmation = React.useCallback(() => { + setConfirmationHovered(false); + setConfirmation(null); + }, []); + + React.useEffect(() => { + if (!confirmation || confirmationHovered) return; const timeout = window.setTimeout( - () => setConfirmation(null), + clearConfirmation, CONFIRMATION_DURATION_MS, ); return () => window.clearTimeout(timeout); - }, [confirmation]); + }, [clearConfirmation, confirmation, confirmationHovered]); + + const currentAudiencePubkeySet = React.useMemo( + () => new Set(currentAudiencePubkeys.map(normalizePubkey).filter(Boolean)), + [currentAudiencePubkeys], + ); + const confirmationIsCurrent = + confirmation?.scope === audienceScope && + confirmation.pubkeys.every((pubkey) => + currentAudiencePubkeySet.has(pubkey), + ); + React.useEffect(() => { + if (confirmation && !confirmationIsCurrent) clearConfirmation(); + }, [clearConfirmation, confirmation, confirmationIsCurrent]); const promoteAgents = React.useCallback( ({ @@ -50,10 +140,12 @@ export function useAutoPinMentionedAgents({ ? getPersistentAgentAudienceRevision(audienceScope) : 0, pubkeys, + reinstateExcluded, requirePreference, }: { expectedRevision?: number; pubkeys: readonly string[]; + reinstateExcluded: boolean; requirePreference: boolean; }) => { if (!audienceScope || (requirePreference && !enabled)) return; @@ -62,6 +154,7 @@ export function useAutoPinMentionedAgents({ ].filter(Boolean); const promotion = promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded, pubkeys: normalizedPubkeys, scope: audienceScope, }); @@ -78,6 +171,7 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; + setConfirmationHovered(false); setConfirmation({ expectedRevision: revision, pubkeys: promotedPubkeys, @@ -88,37 +182,45 @@ export function useAutoPinMentionedAgents({ [audienceScope, enabled, getDisplayName, onPulse], ); const promoteMentionedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: true }), + (promotion: { + expectedRevision?: number; + pubkeys: readonly string[]; + reinstateExcluded?: boolean; + }) => + promoteAgents({ + ...promotion, + reinstateExcluded: promotion.reinstateExcluded ?? false, + requirePreference: true, + }), [promoteAgents], ); const promoteExplicitlyAddressedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: false }), - [promoteAgents], + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => { + promoteAgents({ + ...promotion, + reinstateExcluded: true, + requirePreference: false, + }); + requestPreferenceChange(true); + }, + [promoteAgents, requestPreferenceChange], ); - const dismissConfirmation = React.useCallback( - () => setConfirmation(null), - [], - ); + const dismissConfirmation = clearConfirmation; const turnOffConfirmation = React.useCallback(() => { if (!confirmation) return; - setConfirmation(null); - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: confirmation.expectedRevision, - pubkeys: confirmation.pubkeys, - scope: confirmation.scope, - }); - onTurnOff(); - }, [confirmation, onTurnOff]); + clearConfirmation(); + requestPreferenceChange(false, confirmation); + }, [clearConfirmation, confirmation, requestPreferenceChange]); return { - confirmationTitle: - confirmation?.scope === audienceScope ? confirmation.title : null, + confirmationTitle: confirmationIsCurrent ? confirmation.title : null, + completeOptionsReveal, dismissConfirmation, + openOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered, turnOffConfirmation, }; } diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs new file mode 100644 index 00000000000..747df2ee7ee --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("turning off automatic mentions opens a closed mention picker first", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + const openCalls = []; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => {}, + isMentionOpen: false, + openMentionPicker: (...args) => openCalls.push(args), + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.deepEqual(openCalls, [[4, "first-agent"]]); + assert.equal(turnOffCount, 1); +}); + +test("turning off automatic mentions refreshes an open picker without closing it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + let pickerMutationCount = 0; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => { + pickerMutationCount += 1; + }, + isMentionOpen: true, + openMentionPicker: () => { + pickerMutationCount += 1; + }, + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.equal(pickerMutationCount, 1); + assert.equal(turnOffCount, 1); +}); diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.ts b/desktop/src/features/messages/ui/useComposerMentionPicker.ts index 1a13aa98586..331200e71f5 100644 --- a/desktop/src/features/messages/ui/useComposerMentionPicker.ts +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.ts @@ -5,21 +5,23 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex export function useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }: { mentions: UseMentionsResult; + onTurnOffAutoPinConfirmation: () => void; richText: UseRichTextEditorResult; setIsEmojiPickerOpen: (open: boolean) => void; }) { const { cancelMentionAutocomplete, isMentionOpen, - openMentionPicker, + openMentionPicker: setMentionPickerOpen, updateMentionQuery, } = mentions; const { editor, focus, getPlainTextAndCursor } = richText; - return React.useCallback( + const openMentionPicker = React.useCallback( (insertTrigger = true) => { if (!editor) return; const { text, cursor } = getPlainTextAndCursor(); @@ -30,7 +32,7 @@ export function useComposerMentionPicker({ focus(); return; } - openMentionPicker(cursor, "first-agent"); + setMentionPickerOpen(cursor, "first-agent"); setIsEmojiPickerOpen(false); focus(); return; @@ -55,9 +57,36 @@ export function useComposerMentionPicker({ focus, getPlainTextAndCursor, isMentionOpen, - openMentionPicker, + setMentionPickerOpen, setIsEmojiPickerOpen, updateMentionQuery, ], ); + const openMentionSettings = React.useCallback( + () => openMentionPicker(false), + [openMentionPicker], + ); + const revealMentionSettings = React.useCallback(() => { + if (!editor) return; + const { cursor } = getPlainTextAndCursor(); + setMentionPickerOpen(cursor, "first-agent"); + setIsEmojiPickerOpen(false); + focus(); + }, [ + editor, + focus, + getPlainTextAndCursor, + setIsEmojiPickerOpen, + setMentionPickerOpen, + ]); + const turnOffAutoPinFromConfirmation = React.useCallback(() => { + revealMentionSettings(); + onTurnOffAutoPinConfirmation(); + }, [onTurnOffAutoPinConfirmation, revealMentionSettings]); + + return { + openMentionPicker, + openMentionSettings, + turnOff: turnOffAutoPinFromConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 694bf6a2a55..857f4bf0e05 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; + import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { @@ -59,6 +61,8 @@ type UseDraftPersistLifecycleParams = { * closure to capture the latest text before the effect fires. */ syncComposerContentFromEditor: () => string; + /** Exact editor prefix inserted by automatic addressing, including separator. */ + getImplicitAgentMentionPrefix?: () => string; }; type UseDraftPersistLifecycleResult = { @@ -120,7 +124,17 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix, }: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { + const persistedContent = React.useCallback( + (content: string) => + stripImplicitAgentMentionPrefix( + content, + getImplicitAgentMentionPrefix?.() ?? "", + ), + [getImplicitAgentMentionPrefix], + ); + const pendingImetaForPersistRef = React.useRef([]); const emptyContentIsAuthoritativeRef = React.useRef(false); const isRestoringContentRef = React.useRef(false); @@ -192,7 +206,7 @@ export function useDraftPersistLifecycle({ } const content = emptyContentIsAuthoritativeRef.current ? "" - : syncComposerContentFromEditor(); + : persistedContent(syncComposerContentFromEditor()); persistDraft( effectiveDraftKey, content, diff --git a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts new file mode 100644 index 00000000000..c426ee1df76 --- /dev/null +++ b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { trimMapToSize } from "@/shared/lib/trimMapToSize"; + +type GeneratedMention = { pubkey: string; prefix: string }; + +/** Tracks the leading mentions that automatic addressing inserted per draft. */ +export function useImplicitAgentMentionProvenance( + effectiveDraftKey: string | null | undefined, +) { + const byDraftRef = React.useRef(new Map()); + + const getPrefix = React.useCallback(() => { + if (!effectiveDraftKey) return ""; + return ( + byDraftRef.current + .get(effectiveDraftKey) + ?.map((fragment) => fragment.prefix) + .join("") ?? "" + ); + }, [effectiveDraftKey]); + + const add = React.useCallback( + (insertedFragments: readonly GeneratedMention[]) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + const knownPubkeys = new Set( + fragments.map((fragment) => fragment.pubkey), + ); + byDraftRef.current.set(effectiveDraftKey, [ + ...insertedFragments.filter( + (fragment) => !knownPubkeys.has(fragment.pubkey), + ), + ...fragments, + ]); + trimMapToSize(byDraftRef.current, 200); + }, + [effectiveDraftKey], + ); + + const remove = React.useCallback( + (pubkey: string) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + byDraftRef.current.set( + effectiveDraftKey, + fragments.filter((fragment) => fragment.pubkey !== pubkey), + ); + }, + [effectiveDraftKey], + ); + + return { add, getPrefix, remove }; +} diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index d8e1550549f..30e79362461 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,7 +166,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Address the default agent, or select the highlighted agent", + description: "Address the default agent, or toggle the highlighted agent", keys: "⇧⌘M", keysWindows: "Ctrl+Shift+M", category: "Messages", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 814bd86223e..9c12ebef4fe 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1411,7 +1411,16 @@ declare global { }) => unknown; __BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void; __BUZZ_E2E_QUERY_CLIENT__?: { - invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown; + invalidateQueries: (filters: { + queryKey: readonly unknown[]; + exact?: boolean; + }) => unknown; + getQueryState: (queryKey: readonly unknown[]) => + | { + fetchStatus: "fetching" | "paused" | "idle"; + status: "pending" | "error" | "success"; + } + | undefined; }; __BUZZ_E2E_MD_PARSE_COUNT__?: () => number; /** diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c5a4522ef55..88985fedecd 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -425,10 +425,10 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), ).toHaveCount(0); - await input.fill("local"); + await input.pressSequentially("local"); await page.getByTestId("send-message").click(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "local")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl local")) .toEqual([managedPubkey]); await expect(input).toHaveText("@carl "); @@ -448,7 +448,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), ).toHaveCount(0); - await input.fill("remote"); + await input.pressSequentially("remote"); await page.getByTestId("send-message").click(); const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" }); try { @@ -458,7 +458,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as // In-channel selections send immediately without opening the prompt. } await expect - .poll(() => readOutgoingMentionPubkeys(page, "remote")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl remote")) .toEqual([relayPubkey]); await page.getByTestId("channel-members-trigger").click(); @@ -1673,30 +1673,60 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); -test("relay-only allowlisted agents are visible in channel mentions", async ({ +test("managed agents use the channel roster for membership labels", async ({ page, }) => { await installMockBridge(page, { - relayAgents: [ + managedAgents: [ { - pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, - name: "quinn", - respondTo: "allowlist", - respondToAllowlist: [MOCK_VIEWER_PUBKEY], - channelNames: ["general"], + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "carl", + status: "running", }, ], }); await page.goto("/"); await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + (channelId) => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "channels", + channelId, + "members", + ])?.status, + GENERAL_CHANNEL_ID, + ), + ) + .toBe("success"); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + exact: true, + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + }, + ); const input = page.getByTestId("message-input"); - await input.fill("@quinn"); + await input.fill("@carl"); - const dropdown = autocomplete(page); - await expect(dropdown.getByText("quinn")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); + const carlRow = autocomplete(page).locator("button", { hasText: "carl" }); + await expect(carlRow).toBeVisible(); + await expect(carlRow.getByText("agent")).toBeVisible(); + await expect(carlRow.getByText("not in channel")).toHaveCount(0); }); test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({ @@ -1734,7 +1764,27 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", queryKey: ["relay-agents"], }); }); - await expect(autocomplete(page).getByText("quinn")).toHaveCount(0); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("fetching"); + await expect(autocomplete(page).getByText("quinn")).toBeVisible({ + timeout: 200, + }); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("idle"); await expect(autocomplete(page).getByText("quinn")).toBeVisible(); }); @@ -1917,6 +1967,56 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("selected relay agents are invited as bots before sending", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow.getByText("not in channel")).toHaveCount(0); + await quinnRow.click(); + await page.keyboard.type("hello"); + + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + await page.getByTestId("send-message").click(); + const inviteButton = page.getByRole("button", { + name: "Invite", + exact: true, + }); + await expect(inviteButton).toBeVisible(); + await inviteButton.click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const sendCommands = (await readCommandPayloadLog(page)).slice( + baselinePayloadCount, + ); + const addCommand = sendCommands.find( + (entry) => entry.command === "add_channel_members", + ); + expect(addCommand?.payload).toMatchObject({ + channelId: GENERAL_CHANNEL_ID, + pubkeys: [ALLOWLIST_RELAY_AGENT_PUBKEY], + role: "bot", + }); +}); + test("selected relay agents revoked after the invite prompt cause no side effects", async ({ page, }) => { diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 0b957992e2b..643031454ac 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -5,6 +5,7 @@ import { installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/persistent-agent-audience"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; @@ -84,11 +85,11 @@ async function readComposerCaret(input: Locator) { }); } -async function pressPrimaryShift(page: Page, key: "M") { +async function pressPrimaryShiftM(page: Page) { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); - await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+${key}`); + await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); } async function readOutgoingMentionPubkeys(page: Page, content: string) { @@ -156,6 +157,7 @@ async function emitMockMessage( async function installAudienceFixtures( page: Page, options: { + agentAName?: string; deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; @@ -171,12 +173,13 @@ async function installAudienceFixtures( usersBatchDelayMs?: number; } = {}, ) { + const { agentAName = "Morgarita", ...bridgeOptions } = options; await installMockBridge(page, { - ...options, + ...bridgeOptions, managedAgents: [ { pubkey: AGENT_A, - name: "Morgarita", + name: agentAName, status: "running", channelNames: ["general"], }, @@ -285,6 +288,104 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); +test("keeps the composer and global automatic mention settings synchronized", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(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"); + await expect(composerToggle).toHaveAttribute("data-state", "checked", { + timeout: 1_500, + }); + + await page + .getByTestId("composer-auto-pin-confirmation") + .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(composerToggle).toHaveAttribute("data-state", "unchecked", { + timeout: 1_500, + }); + + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + const settingsToggle = page + .getByTestId("settings-automatic-agent-mentions") + .getByRole("switch", { name: "Automatically mention agents" }); + await expect(settingsToggle).toHaveAttribute("data-state", "unchecked"); + + await page.getByTestId("settings-back-to-app").click(); + await expect( + 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"); + await expect( + composer.getByRole("button", { + name: "Automatically mention Morgarita", + }), + ).toHaveAttribute("aria-pressed", "false"); +}); + +test("hides automatic mention state while disabled without clearing the draft", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + 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 page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.getByTestId("channel-management-archive").click(); + await expect(page.getByTestId("channel-management-unarchive")).toBeVisible(); + 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(); +}); + test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -346,7 +447,7 @@ test("disabling automatic mentions leaves the composer empty after send", async .toContain(AGENT_A); }); -test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then toggles the highlighted agent in place", async ({ page, }) => { await keepMentionedAgentsPinned(page); @@ -356,21 +457,21 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); await expect( page.getByTestId("composer-auto-pin-confirmation"), ).toContainText("alice will be mentioned automatically"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); await input.fill("@Vog"); @@ -378,9 +479,9 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); - await expect(menu).toHaveCount(0); + await expect(menu).toBeVisible(); await expect(input).toHaveText("@Vogue "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_B}`), @@ -391,7 +492,7 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); }); @@ -407,13 +508,13 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async await input.press("ArrowLeft"); await input.press("ArrowLeft"); await expect.poll(() => readComposerCaret(input)).toBe(8); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); await expect.poll(() => readComposerCaret(input)).toBe(15); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); }); @@ -468,12 +569,14 @@ 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: "Stop automatically mentioning Morgarita", + name: "Don't automatically mention Morgarita in this conversation", }), ).toHaveAttribute("aria-pressed", "true"); await menu - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByRole("button", { + name: "Don't automatically mention Morgarita in this conversation", + }) .click(); await expect(input).toHaveText("draft text"); await expect( @@ -724,44 +827,513 @@ 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-keep-agents-pinned-toggle"), + ).toHaveAttribute("data-state", "unchecked"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await expect(autoPinConfirmation).toHaveCount(0); - await composer.getByTestId("message-insert-mention").click(); + await input.press("Escape"); + await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); +}); + +test("the auto-pin popover remains open while hovered", async ({ page }) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await composer.getByTestId("mention-options-trigger").click(); - await expect( - composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toHaveAttribute("data-state", "unchecked"); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await autoPinConfirmation.hover(); + await page.waitForTimeout(4_250); + await expect(autoPinConfirmation).toBeVisible(); await input.press("Escape"); - await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); }); -test("channel automatic mentions carry into threads and stay synchronized", async ({ +test("removing the mention chip dismisses the auto-pin popover", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - await openThread(page); - const channelAutomaticMention = channelComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", ); - const threadAutomaticMention = threadComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, + await expect(autoPinConfirmation).toBeVisible(); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); - await expect(channelAutomaticMention).toBeVisible(); - await expect(threadAutomaticMention).toBeVisible(); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect(autoPinConfirmation).toHaveCount(0); +}); + +test("automatic mentions are scoped to their channel or thread composer", 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 threadComposer(page) + await openThread(page); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).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}`), + ).toHaveCount(0); + + await openThread(page); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); + + await openThread(page, "mock-general-alice"); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); +}); + +test("a thread automatic mention preserves an explicitly unpinned root agent", 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(threadAutomaticMention).toHaveCount(0); - await expect(channelAutomaticMention).toHaveCount(0); + 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(""); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + 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 openGeneral(page); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + const restoredRootInput = channelComposer(page).getByTestId("message-input"); + await restoredRootInput.fill("@cla"); + await expect( + channelComposer(page).getByTestId("mention-autocomplete"), + ).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(restoredRootInput).toHaveText(""); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("one time"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + await input.type("re-added"); + await input.press("Enter"); + + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); +}); + +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 input.type("draft text"); + + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await input.pressSequentially(" continues"); + await expect(input).toHaveText("@Morgarita draft text continues"); + 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), + ) + .toBe("draft text continues"); +}); + +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 input.pressSequentially("@Morgarita authored duplicate"); + + await openThread(page); + await openGeneral(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 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), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("typed deletion preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.pressSequentially("@Morgarita manual after typed deletion"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after typed deletion"); +}); + +test("removing an automatic mention preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await input.pressSequentially("@Morgarita manual after removal"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after removal"); +}); + +test("multiple automatic mentions stay out of persisted drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(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 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), + ) + .toBe("draft text"); +}); + +test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + + await automaticallyMention(composer, "Morgarita"); + await input.pressSequentially("@Morgarita authored duplicate"); + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + + 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), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({ + page, +}) => { + await installAudienceFixtures(page, { agentAName: "claude code" }); + await openGeneral(page); + const originalComposer = channelComposer(page); + await automaticallyMention(originalComposer, "claude code"); + const originalInput = originalComposer.getByTestId("message-input"); + await originalInput.pressSequentially("hello"); + await originalInput.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@claude code hello")) + .toContain(AGENT_A); + await expect(originalInput).toHaveText("@claude code "); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + const expectedContent = "@claude code "; + await expect(input).toHaveText(expectedContent); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + composer.getByRole("button", { name: "Manage automatic agent mentions" }), + ).toBeVisible(); + await page.waitForTimeout(500); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect(input).toBeFocused(); + await expect + .poll(() => readComposerCaret(input)) + .toBe(expectedContent.length); + + await input.pressSequentially("follow-up"); + await expect(input).toHaveText("@claude code follow-up"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); }); test("reduced motion removes addressed agents without spatial animation", async ({ @@ -785,7 +1357,7 @@ test("reduced motion removes addressed agents without spatial animation", async await expect(removeButton).toHaveCSS("transform", "none"); await removeButton.click(); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect(removeButton).toHaveCount(0); }); @@ -832,7 +1404,7 @@ test("captures the lightweight auto-pin popover", async ({ page }) => { const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); const addressControl = composer