diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 19bb21b62fe..89c61bdf60a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -371,7 +371,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); - const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -387,13 +386,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); const channelIntro = isHuddleTranscript ? null : standardChannelIntro; - const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ - activeChannel, - isHuddleTranscript, - messages, - profiles, - threadSummaries, - }); + const { mainTimelineEntries, recentMentions, visibleMessages } = + useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -780,7 +780,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : undefined } onSend={handleSendMessage} - profiles={profiles} + {...{ profiles, recentMentionPubkeys: recentMentions }} showBackgroundUploadProgress={false} placeholder={ timeoutState.active @@ -889,7 +889,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} - profiles={profiles} + {...{ profiles, recentMentionPubkeys: recentMentions }} replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} diff --git a/desktop/src/features/channels/ui/useChannelPaneMessages.ts b/desktop/src/features/channels/ui/useChannelPaneMessages.ts index a2de4bbfe90..168ea665739 100644 --- a/desktop/src/features/channels/ui/useChannelPaneMessages.ts +++ b/desktop/src/features/channels/ui/useChannelPaneMessages.ts @@ -5,6 +5,7 @@ import { } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys"; import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome"; type ChannelPaneMessagesOptions = Pick< @@ -46,5 +47,14 @@ export function useChannelPaneMessages({ [isHuddleTranscript, profiles, threadSummaries, visibleMessages], ); - return { mainTimelineEntries, visibleMessages }; + const recentMentionPubkeys = React.useMemo( + () => getRecentMentionPubkeys(messages, activeChannel?.channelType), + [activeChannel?.channelType, messages], + ); + + return { + mainTimelineEntries, + recentMentions: recentMentionPubkeys, + visibleMessages, + }; } diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs new file mode 100644 index 00000000000..ad00eb296c7 --- /dev/null +++ b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts"; + +const DARIA = "a".repeat(64); +const RIZZ = "b".repeat(64); + +test("hides an address prefix already represented by an inline mention", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys("@Daria please review this", [DARIA], { + daria: DARIA, + }), + [], + ); +}); + +test("keeps a tag-backed prefix when its inline mention was deleted", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys("please review this", [DARIA], { + daria: DARIA, + }), + [DARIA], + ); +}); + +test("filters only addressed agents that are present inline", () => { + assert.deepEqual( + getVisibleAgentAddressPubkeys( + "@Daria please pair with someone", + [DARIA, RIZZ], + { + daria: DARIA, + rizz: RIZZ, + }, + ), + [RIZZ], + ); +}); diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts new file mode 100644 index 00000000000..f96279628f1 --- /dev/null +++ b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts @@ -0,0 +1,20 @@ +import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Keep tag-backed address recipients visible without repeating ordinary inline + * mentions already present in the message body. + */ +export function getVisibleAgentAddressPubkeys( + body: string, + addressedPubkeys: readonly string[], + mentionPubkeysByName: Readonly> | undefined, +): string[] { + const inlineMentionPubkeys = new Set( + orderMentionPubkeysByText(body, mentionPubkeysByName, () => true), + ); + + return addressedPubkeys.filter( + (pubkey) => !inlineMentionPubkeys.has(normalizePubkey(pubkey)), + ); +} diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 3ad358a0d66..659832deadf 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -46,6 +46,7 @@ export type MentionCandidate = { secondaryLabel?: string | null; ownerPubkey?: string | null; isAgent: boolean; + isActiveAgent?: boolean; isManagedAgent?: boolean; isGlobalSearchResult?: boolean; }; diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 82809eaabc0..3d1d5d6ef71 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -202,6 +202,7 @@ export function settleAutocompleteMentionInsert( editor: { storage: object }, tr: Transaction, text: string, + settleCaret = true, ): void { const storage = mentionHighlightStorage(editor); const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text); @@ -222,7 +223,7 @@ export function settleAutocompleteMentionInsert( } } } - tr.setMeta(mentionHighlightKey, true); + if (settleCaret) tr.setMeta(mentionHighlightKey, true); } export function syncMentionHighlightFromProps( diff --git a/desktop/src/features/messages/lib/mentionRanking.test.mjs b/desktop/src/features/messages/lib/mentionRanking.test.mjs index 2e74bba52d4..bdbf1cf11d2 100644 --- a/desktop/src/features/messages/lib/mentionRanking.test.mjs +++ b/desktop/src/features/messages/lib/mentionRanking.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { rankMentionCandidates } from "./mentionRanking.ts"; +import { + pickDefaultAgentCandidate, + rankMentionCandidates, +} from "./mentionRanking.ts"; const CHANNEL_BRAIN_PUBKEY = "1".repeat(64); const OTHER_BRAIN_PUBKEY = "2".repeat(64); @@ -139,3 +142,108 @@ test("rankMentionCandidates: owned teams rank with runnable personas", () => { ["team", "identity"], ); }); + +test("pickDefaultAgentCandidate: active agents outrank stopped channel members", () => { + const stoppedMember = candidate({ + displayName: "Ada", + isActiveAgent: false, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + const runningNonMember = candidate({ + displayName: "Bea", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([stoppedMember, runningNonMember]), + runningNonMember, + ); +}); + +test("pickDefaultAgentCandidate: stable labels break ties instead of roster order", () => { + const vogue = candidate({ + displayName: "Vogue", + isActiveAgent: true, + isAgent: true, + isMember: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + const morgarita = candidate({ + displayName: "Morgarita", + isActiveAgent: true, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + + assert.equal(pickDefaultAgentCandidate([vogue, morgarita]), morgarita); + assert.equal(pickDefaultAgentCandidate([morgarita, vogue]), morgarita); +}); + +test("pickDefaultAgentCandidate: runnable personas break otherwise equal ties", () => { + const plain = candidate({ + displayName: "Zulu", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + const runnable = candidate({ + displayName: "Zulu 2", + isActiveAgent: true, + isAgent: true, + personaId: "active-persona", + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([plain, runnable], new Set(["active-persona"])), + runnable, + ); +}); + +test("pickDefaultAgentCandidate: recent eligible mentions outrank the fallback ranking", () => { + const stoppedRecentMember = candidate({ + displayName: "Ada", + isActiveAgent: false, + isAgent: true, + isMember: true, + pubkey: CHANNEL_BRAIN_PUBKEY, + }); + const runningNonMember = candidate({ + displayName: "Bea", + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate( + [runningNonMember, stoppedRecentMember], + new Set(), + [CHANNEL_BRAIN_PUBKEY], + ), + stoppedRecentMember, + ); +}); + +test("pickDefaultAgentCandidate: skips recent pubkeys that are not eligible candidates", () => { + const runningAgent = candidate({ + isActiveAgent: true, + isAgent: true, + pubkey: OTHER_BRAIN_PUBKEY, + }); + + assert.equal( + pickDefaultAgentCandidate([runningAgent], new Set(), ["f".repeat(64)]), + runningAgent, + ); +}); + +test("pickDefaultAgentCandidate: returns null without an addressable agent", () => { + assert.equal(pickDefaultAgentCandidate([]), null); + assert.equal(pickDefaultAgentCandidate([candidate()]), null); +}); diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 09b9e03de7b..3df5bba0b0a 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -3,6 +3,7 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; isAgent: boolean; + isActiveAgent?: boolean; isMember: boolean; kind: "identity" | "persona" | "team"; personaId?: string | null; @@ -51,6 +52,58 @@ function scoreMentionCandidateLabel( return null; } +export function pickDefaultAgentCandidate( + candidates: readonly T[], + activePersonaIds: ReadonlySet = new Set(), + recentMentionPubkeys: readonly string[] = [], +): T | null { + const recentMentionRankByPubkey = new Map( + recentMentionPubkeys.map((pubkey, index) => [ + normalizePubkey(pubkey), + index, + ]), + ); + return ( + candidates + .filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey)) + .sort((left, right) => { + const leftRecentRank = left.pubkey + ? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey)) + : undefined; + const rightRecentRank = right.pubkey + ? recentMentionRankByPubkey.get(normalizePubkey(right.pubkey)) + : undefined; + const recentDiff = + (leftRecentRank ?? recentMentionPubkeys.length) - + (rightRecentRank ?? recentMentionPubkeys.length); + if (recentDiff !== 0) return recentDiff; + const activeDiff = + Number(right.isActiveAgent === true) - + Number(left.isActiveAgent === true); + if (activeDiff !== 0) return activeDiff; + const memberDiff = Number(right.isMember) - Number(left.isMember); + if (memberDiff !== 0) return memberDiff; + const runnableDiff = + Number( + Boolean(right.personaId) && + activePersonaIds.has(right.personaId ?? ""), + ) - + Number( + Boolean(left.personaId) && + activePersonaIds.has(left.personaId ?? ""), + ); + if (runnableDiff !== 0) return runnableDiff; + const labelDiff = (left.displayName ?? "").localeCompare( + right.displayName ?? "", + undefined, + { sensitivity: "base" }, + ); + if (labelDiff !== 0) return labelDiff; + return (left.pubkey ?? "").localeCompare(right.pubkey ?? ""); + })[0] ?? null + ); +} + export function rankMentionCandidates( candidates: readonly T[], query: string, diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index 9be4f2c4a57..cd84aa6e8c4 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -3,7 +3,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { formatOwnerLabel } from "@/features/profile/lib/identity"; import type { ChannelRole, ChannelType } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { TeamMentionMember } from "./mentionCandidates"; +import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates"; +import { mentionCandidateLabel } from "./mentionCandidates"; +import { pickDefaultAgentCandidate } from "./mentionRanking"; export type MentionSuggestionCandidate = { kind: "identity" | "persona" | "team"; @@ -74,3 +76,26 @@ export function mapMentionCandidateToSuggestion(opts: { role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, }; } + +export function pickDefaultAgentSuggestion(opts: { + activePersonaIds: ReadonlySet; + agentProvenanceReady: boolean; + candidates: readonly MentionCandidate[]; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; +}): MentionSuggestion | null { + const candidate = pickDefaultAgentCandidate( + opts.candidates, + opts.activePersonaIds, + opts.recentMentionPubkeys, + ); + if (!candidate) return null; + return mapMentionCandidateToSuggestion({ + ...opts, + candidate, + label: mentionCandidateLabel(candidate), + }); +} diff --git a/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs new file mode 100644 index 00000000000..f8add9c84a5 --- /dev/null +++ b/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getRecentMentionPubkeys } from "./recentMentionPubkeys.ts"; + +const AUTHOR = "a".repeat(64); +const OLDER = "1".repeat(64); +const LATEST_FIRST = "2".repeat(64); +const LATEST_LAST = "3".repeat(64); +const REPLY_AUTHOR = "4".repeat(64); + +function message(createdAt, tags) { + return { + id: String(createdAt), + createdAt, + pubkey: AUTHOR, + author: "Author", + time: "", + body: "", + depth: 0, + tags, + }; +} + +test("returns loaded channel mentions newest-first and excludes structural author tags", () => { + assert.deepEqual( + getRecentMentionPubkeys([ + message(1, [ + ["p", AUTHOR], + ["p", OLDER], + ]), + message(2, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ["p", LATEST_LAST], + ]), + ]), + [LATEST_LAST, LATEST_FIRST, OLDER], + ); +}); + +test("keeps a top-level mention when the event omits its structural author tag", () => { + assert.deepEqual( + getRecentMentionPubkeys([message(1, [["p", LATEST_FIRST]])]), + [LATEST_FIRST], + ); +}); + +test("filters desktop structural self-tags by identity", () => { + const parent = message(1, []); + assert.deepEqual( + getRecentMentionPubkeys([ + parent, + { + ...message(2, [ + ["p", REPLY_AUTHOR], + ["p", LATEST_FIRST], + ]), + id: "desktop-reply", + parentId: parent.id, + pubkey: REPLY_AUTHOR, + }, + ]), + [LATEST_FIRST], + ); +}); + +test("keeps an sdk-shaped reply mention that matches the parent author", () => { + const parent = message(1, []); + assert.deepEqual( + getRecentMentionPubkeys([ + parent, + { + ...message(2, [["p", AUTHOR]]), + id: "sdk-reply", + parentId: parent.id, + pubkey: REPLY_AUTHOR, + }, + ]), + [AUTHOR], + ); +}); + +test("ignores DM participant fan-out tags", () => { + assert.deepEqual( + getRecentMentionPubkeys( + [ + message(1, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ["p", LATEST_LAST], + ]), + ], + "dm", + ), + [], + ); +}); + +test("deduplicates repeated mentions at their newest position", () => { + assert.deepEqual( + getRecentMentionPubkeys([ + message(1, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ]), + message(2, [ + ["p", AUTHOR], + ["p", LATEST_FIRST], + ]), + ]), + [LATEST_FIRST], + ); +}); diff --git a/desktop/src/features/messages/lib/recentMentionPubkeys.ts b/desktop/src/features/messages/lib/recentMentionPubkeys.ts new file mode 100644 index 00000000000..5150904ddb7 --- /dev/null +++ b/desktop/src/features/messages/lib/recentMentionPubkeys.ts @@ -0,0 +1,41 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { ChannelType } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Return explicitly addressed pubkeys from the loaded channel window, newest + * first. DM `p` tags fan out to every participant and cannot distinguish inline + * mentions, so DMs deliberately fall back to the non-recency ranking ladder. + * Desktop-authored stream events may include the message author as a structural + * `p` tag, while SDK-authored events omit it. Filter by author identity rather + * than tag position so an SDK mention of a reply target remains eligible. + */ +export function getRecentMentionPubkeys( + messages: readonly TimelineMessage[], + channelType?: ChannelType | null, +): string[] { + if (channelType === "dm") return []; + + const seen = new Set(); + const recent: string[] = []; + + for ( + let messageIndex = messages.length - 1; + messageIndex >= 0; + messageIndex -= 1 + ) { + const message = messages[messageIndex]; + const authorPubkey = normalizePubkey(message.pubkey ?? ""); + const tags = message.tags ?? []; + for (let tagIndex = tags.length - 1; tagIndex >= 0; tagIndex -= 1) { + const tag = tags[tagIndex]; + if (tag[0] !== "p" || !tag[1]) continue; + const pubkey = normalizePubkey(tag[1]); + if (!pubkey || pubkey === authorPubkey || seen.has(pubkey)) continue; + seen.add(pubkey); + recent.push(pubkey); + } + } + + return recent; +} diff --git a/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts new file mode 100644 index 00000000000..0ac56e72eb2 --- /dev/null +++ b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts @@ -0,0 +1,24 @@ +import * as React from "react"; +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function useActiveAgentPubkeys( + managedAgents?: readonly ManagedAgent[], + relayAgents?: readonly RelayAgent[], +): ReadonlySet { + return React.useMemo( + () => + new Set([ + ...(managedAgents ?? []) + .filter( + (agent) => + agent.status === "running" || agent.status === "deployed", + ) + .map((agent) => normalizePubkey(agent.pubkey)), + ...(relayAgents ?? []) + .filter((agent) => agent.status !== "offline") + .map((agent) => normalizePubkey(agent.pubkey)), + ]), + [managedAgents, relayAgents], + ); +} diff --git a/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts b/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts new file mode 100644 index 00000000000..5748ca2098a --- /dev/null +++ b/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts @@ -0,0 +1,50 @@ +import * as React from "react"; +import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; +import type { MentionCandidate } from "./mentionCandidates"; +import { pickDefaultAgentSuggestion } from "./mentionSuggestionMapping"; + +export function useDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, +}: { + activePersonaIds: ReadonlySet; + agentProvenanceReady: boolean; + candidates: readonly MentionCandidate[]; + channelType?: ChannelType | null; + currentPubkey?: string | null; + ownerProfiles?: UserProfileLookup; + profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; +}): () => MentionSuggestion | null { + return React.useCallback( + () => + pickDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, + }), + [ + activePersonaIds, + agentProvenanceReady, + candidates, + channelType, + currentPubkey, + ownerProfiles, + profiles, + recentMentionPubkeys, + ], + ); +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 30b7a1f48a5..1a6ca1be4a7 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -37,6 +37,8 @@ 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"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; @@ -61,9 +63,12 @@ import { type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; -const MENTION_DEBOUNCE_MS = 120; -const MENTION_SUGGESTION_LIMIT = 50; -type UseMentionsOptions = { channelType?: ChannelType | null }; +const MENTION_DEBOUNCE_MS = 120, + MENTION_SUGGESTION_LIMIT = 50; +type UseMentionsOptions = { + channelType?: ChannelType | null; + recentMentionPubkeys?: readonly string[]; +}; export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -170,6 +175,10 @@ export function useMentions( ), [relayAgentsQuery.data], ); + const activeAgentPubkeys = useActiveAgentPubkeys( + managedAgentsQuery.data, + relayAgentsQuery.data, + ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -276,6 +285,7 @@ export function useMentions( ? (candidate.displayName ?? current.displayName) : (current.displayName ?? candidate.displayName), isAgent: current.isAgent || candidate.isAgent, + isActiveAgent: current.isActiveAgent || candidate.isActiveAgent, isMember: current.isMember || candidate.isMember, personaId: current.personaId ?? candidate.personaId, personaName: current.personaName ?? candidate.personaName ?? null, @@ -321,6 +331,7 @@ export function useMentions( member.role === "bot" || managedAgentNamesByPubkey.has(pubkey) || relayAgentNamesByPubkey.has(pubkey), + isActiveAgent: activeAgentPubkeys.has(pubkey), ownerPubkey: profile?.ownerPubkey ?? null, personaName: personaNameByPubkey.get(pubkey) ?? null, role: member.role, @@ -342,6 +353,7 @@ export function useMentions( (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: agent.ownerPubkey, isAgent: true, + isActiveAgent: agent.status !== "offline", }); } for (const agent of managedAgentsQuery.data ?? []) { @@ -351,6 +363,8 @@ export function useMentions( displayName: agent.name, isMember: false, isAgent: true, + isActiveAgent: + agent.status === "running" || agent.status === "deployed", isManagedAgent: true, personaId: agent.personaId ?? undefined, personaName: @@ -406,6 +420,7 @@ export function useMentions( ); }, [ activePersonaById, + activeAgentPubkeys, activePersonas, userSearchResults, canSearchGlobalUsers, @@ -534,6 +549,16 @@ export function useMentions( ownerProfilesQuery.data?.profiles, profiles, ]); + const getDefaultAgentSuggestion = useDefaultAgentSuggestion({ + activePersonaIds, + agentProvenanceReady: agentDirectoriesReady, + candidates: mentionCandidates, + channelType: options?.channelType, + currentPubkey, + ownerProfiles: ownerProfilesQuery.data?.profiles, + profiles, + recentMentionPubkeys: options?.recentMentionPubkeys, + }); const fetchMoreSuggestions = React.useCallback(() => { if (userSearchQuery.hasNextPage && !userSearchQuery.isFetchingNextPage) { void userSearchQuery.fetchNextPage(); @@ -938,6 +963,7 @@ export function useMentions( return { cancelMentionAutocomplete, clearMentions, + getDefaultAgentSuggestion, extractMentionPersonas, extractMentionPubkeys: extractMentionPubkeysForCurrentMentions, revalidateMentionPubkeys, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 36a75f32e5d..87574e79720 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -71,6 +71,8 @@ export type AutocompleteEdit = { replaceFromOffset: number; replaceToOffset: number; insertText: string; + /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ + preserveSelection?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -824,6 +826,7 @@ export function useRichTextEditor({ toOffset: number, text: string, customEmojiShortcode?: string, + preserveSelection = false, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -856,19 +859,23 @@ export function useRichTextEditor({ } const tr = editor.state.tr.insertText(text, fromPM, toPM); - // Place cursor at the end of the inserted text. We map `toPM` (the - // right end of the replaced range) through the transaction's - // mapping — that's the post-transaction position right after the - // inserted text, valid even if mark normalisation shifted things. - // (Mapping `fromPM + text.length` directly would be a pre-image - // position that may not exist in the original doc, which throws - // "Position N out of range".) - const cursorPM = tr.mapping.map(toPM); - tr.setSelection(TextSelection.create(tr.doc, cursorPM)); - settleAutocompleteMentionInsert(editor, tr, text); + if (preserveSelection) { + tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping)); + } else { + // Place cursor at the end of the inserted text. We map `toPM` (the + // right end of the replaced range) through the transaction's + // mapping — that's the post-transaction position right after the + // inserted text, valid even if mark normalisation shifted things. + // (Mapping `fromPM + text.length` directly would be a pre-image + // position that may not exist in the original doc, which throws + // "Position N out of range".) + const cursorPM = tr.mapping.map(toPM); + tr.setSelection(TextSelection.create(tr.doc, cursorPM)); + } + settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); editor.view.focus(); - reassertMentionCaretAfterFocus(editor.view); + if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 78c14aeef9c..4fe40da4b37 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -78,9 +78,8 @@ test("mention control expands with automatically mentioned agents", async () => assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/); assert.match(manage.parentElement?.className ?? "", /(?:^|\s)pl-2(?:\s|$)/); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", - /(?:^|\s)pr-1(?:\s|$)/, + manage.parentElement?.className ?? "", + /(?:^|\s)pr-1\.5(?:\s|$)/, ); assert.match( view.getByRole("button", { name: "Manage automatic agent mentions" }) @@ -111,17 +110,21 @@ test("mention control expands with automatically mentioned agents", async () => /scale\(0.8\)/, ); } - const remove = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", - }); + const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); assert.match( remove.querySelector("span.absolute")?.className ?? "", /group-hover\/address:opacity-100/, ); fireEvent.click(remove); assert.deepEqual(removed, ["agent-pubkey"]); - fireEvent.click( - view.getByRole("button", { name: "Manage automatic agent mentions" }), + view.rerender(renderButton([])); + const exitingLocks = view.getByTestId("composer-address-locks"); + assert.match(exitingLocks.className, /(?:^|\s)overflow-hidden(?:\s|$)/); + assert.match( + view.getByRole("button", { name: "Mention someone" }).parentElement + ?.className ?? "", + /(?:^|\s)pr-1\.5(?:\s|$)/, ); + fireEvent.click(view.getByRole("button", { name: "Mention someone" })); assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index 2c8c28f5de3..f141a4d2fdc 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -9,6 +9,7 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export type ComposerAddressAgent = { @@ -58,7 +59,7 @@ function AddressedAgentAvatar({ return ( void; + onConfirmationTurnOff?: () => void; onCaptureSelection: () => void; onOpen: () => void; onRemove: (pubkey: string) => void; @@ -142,89 +149,161 @@ export function ComposerMentionButton({ const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : []; const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0; const hasAgents = visibleAgents.length > 0; + const shouldReduceMotion = useReducedMotion(); + const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents); const newlyAddedAgentPubkeys = useNewlyAddedAgentPubkeys(visibleAgents); + React.useEffect(() => { + if (hasAgents) setShowActiveChrome(true); + }, [hasAgents]); + return ( -
{ + if (!open) onConfirmationDismiss?.(); + }} + open={Boolean(confirmationTitle)} > - - + +
+ + + + + + {hasAgents + ? "Manage automatic agent mentions" + : "Mention someone"} + + + { + if (!hasAgents) setShowActiveChrome(false); + }} + > + {hasAgents ? ( + + + {visibleAgents.map((agent) => ( + + + onRemove(agent.pubkey)} + transition={ + shouldReduceMotion + ? { duration: 0 } + : addressEntryTransition + } + type="button" + > + + + + + + + Stop automatically mentioning {agent.displayName} + + + ))} + + + + ) : null} + +
+
+ {confirmationTitle ? ( + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="right" + sideOffset={8} + style={{ width: "max-content" }} + > + {confirmationTitle} -
- - {hasAgents ? "Manage automatic agent mentions" : "Mention someone"} - -
- {hasAgents ? ( - - - {visibleAgents.map((agent) => ( - - - onRemove(agent.pubkey)} - transition={addressEntryTransition} - type="button" - > - - - - - - - Stop automatically mentioning {agent.displayName} - - - ))} - - - + ) : null} -
+ ); } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 1adaefd8320..98c600e87f1 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -4,7 +4,6 @@ import { useChannelLinks, type ChannelSuggestion, } from "@/features/messages/lib/useChannelLinks"; -import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; @@ -29,7 +28,6 @@ import { } from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; import { - getPersistentAgentAudienceRevision, getPersistentAgentAudienceScope, usePersistentAgentAudience, } from "@/features/messages/lib/persistentAgentAudience"; @@ -38,10 +36,6 @@ import { useKeepMentionedAgentsPinned, } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { - hasMentionClipboardHtml, - normalizeMentionClipboardHtml, -} from "@/features/messages/lib/normalizeMentionClipboard"; import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { type AutocompleteEdit, @@ -51,7 +45,6 @@ import { import { useLinkEditor } from "@/features/messages/lib/useLinkEditor"; import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; -import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; @@ -68,6 +61,7 @@ import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut"; import { useComposerMentionPicker } from "./useComposerMentionPicker"; import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents"; import { useComposerContentState } from "./useComposerContentState"; +import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; @@ -98,6 +92,7 @@ function MessageComposerImpl({ onSend, placeholder, profiles, + recentMentionPubkeys, replyTarget = null, mediaController, showBackgroundUploadProgress = true, @@ -122,8 +117,6 @@ function MessageComposerImpl({ } = useComposerLinkPreviews(previewContent, editTarget == null); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); - const [mentionOptionsOpenRequest, setMentionOptionsOpenRequest] = - React.useState(0); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< Set >(() => new Set()); @@ -154,6 +147,7 @@ function MessageComposerImpl({ } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { channelType, + recentMentionPubkeys, }); const channelLinks = useChannelLinks(); const customEmoji = useCustomEmoji(); @@ -213,12 +207,16 @@ function MessageComposerImpl({ const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); const isSubmitLockedRef = React.useRef(false); + const [isSubmitLocked, setIsSubmitLocked] = React.useState(false); const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); const editTargetRef = React.useRef(editTarget); const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys); const ownerPubkeyRef = React.useRef(ownerPubkey); + const syncAddressedAgentsFromTextRef = React.useRef<(text: string) => void>( + () => {}, + ); disabledRef.current = disabled; isSendingRef.current = isSending; isUploadingRef.current = media.isUploading; @@ -276,6 +274,9 @@ function MessageComposerImpl({ onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); setPreviewContent(linkPreviewContent); + if (!isSubmitLockedRef.current && !editTargetRef.current) { + syncAddressedAgentsFromTextRef.current(text); + } mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -297,14 +298,38 @@ function MessageComposerImpl({ const persistentAudience = usePersistentAgentAudience(audienceScope); const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); const addressPulse = useAddressMentionPulse(); - const openMentionOptionsRef = React.useRef<() => void>(() => {}); - const addInlineAgentMentionsToAudience = useAutoPinMentionedAgents({ + const { + confirmationTitle: autoPinConfirmationTitle, + dismissConfirmation: dismissAutoPinConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation: turnOffAutoPinConfirmation, + } = useAutoPinMentionedAgents({ audienceScope, enabled: keepMentionedAgentsPinned, getDisplayName: mentions.getMentionDisplayName, - onOpenOptions: () => openMentionOptionsRef.current(), onPulse: addressPulse.pulseOne, + onTurnOff: () => setKeepMentionedAgentsPinned(false), }); + 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, @@ -315,8 +340,23 @@ function MessageComposerImpl({ emojiAutocomplete, mentions, onAddressedAgentsSendStarted: addressPulse.pulseMany, + onAddressedAgentsComposerCleared: (pubkeys) => + restoreAddressedAgentMentionsRef.current(pubkeys), onAddressedAgentsSendFailed: addressPulse.shakeMany, - onInlineAgentMentionsSent: addInlineAgentMentionsToAudience, + onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { + if (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); + }, + ); + }, onPrepareSendChannel, onSendRef, richText, @@ -394,6 +434,7 @@ function MessageComposerImpl({ edit.replaceToOffset, edit.insertText, edit.customEmojiShortcode, + edit.preserveSelection, ); }, [richText.replacePlainTextRange], @@ -403,17 +444,30 @@ function MessageComposerImpl({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, } = useAgentAddressLockPicker({ applyAutocompleteEdit, audience: persistentAudience, audienceScope, mentions, + onAddressAgentMention: (suggestion) => + promoteExplicitlyAddressedAgents({ + pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], + }), + onAutoPinAgentMention: (suggestion) => { + promoteMentionedAgents({ + pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], + }); + }, onPulseAddressLock: addressPulse.pulseOne, profiles, richText, }); + restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; + syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -481,15 +535,12 @@ function MessageComposerImpl({ () => openMentionPicker(false), [openMentionPicker], ); - const openMentionOptions = React.useCallback(() => { - openMentionSettings(); - setMentionOptionsOpenRequest((request) => request + 1); - }, [openMentionSettings]); - openMentionOptionsRef.current = openMentionOptions; const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ enabled: Boolean(audienceScope && editTarget == null), + lockedAgent: lockedAgents[0], mentions, onOpenPicker: openMentionPicker, + onSelect: selectMentionSuggestion, onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { @@ -561,6 +612,7 @@ function MessageComposerImpl({ return; } isSubmitLockedRef.current = true; + setIsSubmitLocked(true); onPreparingMentionSendChange?.(true); try { const preparedLinkPreviews = getReadyLinkPreviewTags().some( @@ -570,9 +622,6 @@ function MessageComposerImpl({ : prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates()); await mentionSendFlow.sendMessageWithMentionFlow({ addressedAgentPubkeys: persistentAudience.pubkeys, - audienceRevision: audienceScope - ? getPersistentAgentAudienceRevision(audienceScope) - : 0, capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, @@ -589,6 +638,7 @@ function MessageComposerImpl({ }); } finally { isSubmitLockedRef.current = false; + setIsSubmitLocked(false); onPreparingMentionSendChange?.(false); } }, [ @@ -615,7 +665,6 @@ function MessageComposerImpl({ syncComposerContentFromEditor, onCaptureSendContext, onPreparingMentionSendChange, - audienceScope, persistentAudience.pubkeys, isEditSubmissionLocked, effectiveDraftKey, @@ -713,74 +762,12 @@ function MessageComposerImpl({ onCancelEdit, ], ); - // ── Media paste + ⌘K link shortcut via Tiptap editorProps ────────── - const uploadFileRef = React.useRef(media.uploadFile); - uploadFileRef.current = media.uploadFile; - React.useEffect(() => { - if (!richText.editor) return; - richText.editor.setOptions({ - editorProps: { - ...richText.editor.options.editorProps, - handlePaste: (_view, event) => { - // --- File paste --- - // Any actual file (image, video, document, …) pastes as an - // attachment. String/text items have kind "string", so plain-text - // and code-block paste fall through to the handlers below. - const items = Array.from(event.clipboardData?.items ?? []); - const mediaItem = items.find((item) => item.kind === "file"); - if (mediaItem) { - const file = mediaItem.getAsFile(); - if (file) { - void uploadFileRef.current(file); - } - return true; - } - // --- Buzz code-block paste --- - // The code block copy button writes a small Buzz marker alongside - // plain text. Use it to paste back as a literal code block so Markdown - // parsing cannot reshape indentation, fence markers, or headings. - const codeBlockText = getBuzzCodeBlockClipboardText( - event.clipboardData, - ); - if (codeBlockText !== null) { - event.preventDefault(); - richText.editor - ?.chain() - .focus() - .insertContent([ - { - type: "codeBlock", - content: - codeBlockText.length > 0 - ? [{ type: "text", text: codeBlockText }] - : [], - }, - { type: "paragraph" }, - ]) - .run(); - scrollComposerToBottom(); - return true; - } - // Restore Buzz snapshots before normal styled-HTML normalization. - if (handleAgentSnapshotPaste(event, media.setPendingImeta)) - return true; - // Strip mention/channel wrappers that Tiptap would misread as bold. - const html = event.clipboardData?.getData("text/html"); - if (html && hasMentionClipboardHtml(html)) { - const cleanHtml = normalizeMentionClipboardHtml(html); - event.preventDefault(); - _view.pasteHTML(cleanHtml); - return true; - } - const plainText = event.clipboardData?.getData("text/plain") ?? ""; - if (plainText.includes("\n")) { - scrollComposerToBottom(); - } - return false; - }, - }, - }); - }, [media.setPendingImeta, richText.editor, scrollComposerToBottom]); + useComposerPasteHandler({ + editor: richText.editor, + scrollToBottom: scrollComposerToBottom, + setPendingImeta: media.setPendingImeta, + uploadFile: media.uploadFile, + }); // ── Send button state ─────────────────────────────────────────────── const sendDisabled = composerDisabled || @@ -858,6 +845,7 @@ function MessageComposerImpl({ layoutMode === "standalone" && "backdrop-blur-md dark:backdrop-blur-xl", )} + data-submit-locked={isSubmitLocked ? "true" : "false"} data-testid="message-composer" onDragEnter={ownsDropZone ? media.handleDragEnter : undefined} onDragLeave={ownsDropZone ? media.handleDragLeave : undefined} @@ -904,7 +892,6 @@ function MessageComposerImpl({ ? setKeepMentionedAgentsPinned : undefined } - openOptionsRequest={mentionOptionsOpenRequest} onToggleAlwaysAddressAgent={ audienceScope && editTarget == null ? toggleAlwaysAddressAgent @@ -973,6 +960,7 @@ function MessageComposerImpl({ Promise; placeholder?: string; profiles?: UserProfileLookup; + /** Explicit mention pubkeys from the loaded channel window, newest first. */ + recentMentionPubkeys?: readonly string[]; replyTarget?: { author: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index d4e2284b85f..5d5a1f5876e 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -26,6 +26,7 @@ const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ addressedAgents = NO_ADDRESSED_AGENTS, + autoPinConfirmationTitle, composerDisabled, editor, extraActions, @@ -35,6 +36,8 @@ export const MessageComposerToolbar = React.memo( isSending, isUploading, onCaptureSelection, + onAutoPinConfirmationDismiss, + onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, @@ -47,6 +50,7 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey, }: { addressedAgents?: readonly ComposerAddressAgent[]; + autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -56,6 +60,8 @@ export const MessageComposerToolbar = React.memo( isSending: boolean; isUploading: boolean; onCaptureSelection: () => void; + onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; @@ -175,7 +181,10 @@ export const MessageComposerToolbar = React.memo( > import("./DiffMessage")); @@ -280,10 +280,12 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); const addressedAgentPubkeys = React.useMemo(() => { - return getAgentAddressMentionPubkeys(message.tags).filter( - isKnownAgentPubkey, + return getVisibleAgentAddressPubkeys( + message.body, + getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), + mentionPubkeysByName, ); - }, [isKnownAgentPubkey, message.tags]); + }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); const agentAddressPrefix = addressedAgentPubkeys.length > 0 ? ( Promise; profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -184,6 +185,7 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, + recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, @@ -851,6 +853,7 @@ export function MessageThreadPanel({ : `Reply in thread to ${threadHead.author}` } profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index e98763a4a51..b95528dd939 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+Enter opens the picker or toggles in place", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const opened = []; + const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { }; const createEvent = () => ({ altKey: false, + code: "KeyM", ctrlKey: !isMacPlatform(), - key: "Enter", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { useAlwaysAddressShortcut({ enabled: true, mentions: { + getDefaultAgentSuggestion: () => suggestion, isMentionOpen, mentionSelectedIndex: 0, suggestions: [suggestion], }, - onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onOpenPicker: () => {}, + onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, ); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(opened, [false]); - assert.deepEqual(toggled, []); + 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]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion, suggestion]); +}); + +test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const lockedAgent = { + avatarUrl: null, + displayName: "Agent Ada", + pubkey: "agent-a", + }; + const defaultAgent = { + displayName: "Agent Bea", + isAgent: true, + pubkey: "agent-b", + }; + const toggled = []; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + lockedAgent, + mentions: { + getDefaultAgentSuggestion: () => defaultAgent, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => {}, + onSelect: () => {}, + onToggle: (value) => toggled.push(value), + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]); +}); + +test("primary+Shift+M opens the picker when no default agent is ready", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + let opened = 0; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + getDefaultAgentSuggestion: () => null, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => { + opened += 1; + }, + onSelect: () => {}, + onToggle: () => {}, + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 4acd89cf0a5..95e0af9e06b 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -23,7 +23,7 @@ afterEach(async () => { after(() => dom.window.close()); -test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { +test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul const addedPubkeys = []; const pulsedPubkeys = []; let cancelCount = 0; - const text = "Ask @Agent Ada later @"; + const text = "@"; const mentions = { cancelMentionAutocomplete: () => { cancelCount += 1; @@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }, ], getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: true, + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); @@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul ); }); +test("always addressing a new agent delegates the first add for immediate confirmation", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const addressedSuggestions = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAddressAgentMention: (value) => addressedSuggestions.push(value), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText: { + getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }), + }, + }), + ); + + act(() => result.current.toggleAlwaysAddressAgent(suggestion)); + + assert.deepEqual(addressedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 4, + replaceToOffset: 15, + insertText: "", + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); @@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("an already addressed agent must not be inserted"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); }); -test("selecting an agent from a typed query leaves the inline mention for send", async () => { +test("selecting an agent from a typed query immediately auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const autoPinnedSuggestions = []; const appliedEdits = []; const addedPubkeys = []; const pulsedPubkeys = []; @@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send", cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 5, @@ -230,18 +301,19 @@ test("selecting an agent from a typed query leaves the inline mention for send", audience, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), ); - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + act(() => result.current.selectMentionSuggestion(suggestion)); assert.deepEqual(appliedEdits, [ { @@ -250,11 +322,151 @@ test("selecting an agent from a typed query leaves the inline mention for send", insertText: "@Agent Ada ", }, ]); + assert.deepEqual(autoPinnedSuggestions, [suggestion]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, []); assert.equal(result.current.announcement, ""); }); +test("selecting a human mention never changes automatic addressing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const autoPinnedSuggestions = []; + const appliedEdits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Alice", + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }), + }, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }), + }, + }), + ); + + act(() => + result.current.selectMentionSuggestion({ + pubkey: "human-pubkey", + displayName: "Alice", + isAgent: false, + }), + ); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, []); +}); + +test("removing the last agent chip clears its automatic address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const mentionRefsByText = { + "@Agent Ada first @Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "@Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "": [], + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["agent-pubkey", "existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [], + getMentionDisplayName: () => "Agent Ada", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => result.current.trackMentionAddressedAgent("agent-pubkey")); + act(() => + result.current.syncAddressedAgentsFromText( + "@Agent Ada first @Agent Ada second", + ), + ); + act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second")); + assert.deepEqual(removedPubkeys, []); + + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); +}); + +test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => { + if (text === "@Alice @Existing Agent") { + return [ + { displayName: "Alice", pubkey: "human-pubkey", isAgent: false }, + { + displayName: "Existing Agent", + pubkey: "existing-lock", + isAgent: true, + }, + ]; + } + return text + ? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }] + : []; + }, + getMentionDisplayName: () => "Existing Agent", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => + result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), + ); + act(() => result.current.syncAddressedAgentsFromText("@Alice")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); +}); + test("selecting an agent from the explicit picker auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -267,10 +479,13 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("explicit picker selections must become addressing"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -299,7 +514,13 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn const pulsedPubkeys = []; const mentions = { cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], + getDraftMentionRefs: () => [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ replaceFromOffset: 0, @@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn mentionStartIndex: 0, }; const richText = { - getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + getPlainTextAndCursor: () => ({ + text: "@Agent Ada keep this authored text", + cursor: 35, + }), }; const { result, rerender } = renderHook( ({ pubkeys }) => @@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + assert.deepEqual(appliedEdits, []); rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 8d5a87b9b8f..17883900ecb 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; function buildMentionRemovalEdits( text: string, displayNames: readonly string[], - queryStart: number, - cursor: number, + queryRange?: { start: number; end: number }, ): AutocompleteEdit[] { const ranges = displayNames.flatMap((displayName) => getMentionOffsets(text, displayName).map((start) => { @@ -26,10 +25,12 @@ function buildMentionRemovalEdits( return { start, end }; }), ); - ranges.push({ - start: Math.max(0, Math.min(queryStart, text.length)), - end: Math.max(0, Math.min(cursor, text.length)), - }); + if (queryRange) { + ranges.push({ + start: Math.max(0, Math.min(queryRange.start, text.length)), + end: Math.max(0, Math.min(queryRange.end, text.length)), + }); + } const merged = ranges .filter(({ start, end }) => start < end) @@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({ audience, audienceScope, mentions, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, profiles, richText, @@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({ audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; + onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.clear(); } const lockedAgentNamesRef = React.useRef(new Map()); + const visibleAgentMentionPubkeysRef = React.useRef(new Set()); + const mentionSyncScopeRef = React.useRef(audienceScope); + if (mentionSyncScopeRef.current !== audienceScope) { + mentionSyncScopeRef.current = audienceScope; + visibleAgentMentionPubkeysRef.current.clear(); + } const [announcement, setAnnouncement] = React.useState(""); const lockedAgents = React.useMemo( () => @@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); - const consumeAddressSuggestion = React.useCallback( - ( - suggestion: MentionSuggestion, - { removeInlineMentions }: { removeInlineMentions: boolean }, - ): string | null => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return null; - - const { text, cursor } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = removeInlineMentions - ? mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) - .map((ref) => ref.displayName) - : []; - mentions.cancelMentionAutocomplete(); - for (const edit of buildMentionRemovalEdits( - text, - matchingDisplayNames, - mentions.mentionStartIndex, - cursor, - )) { - applyAutocompleteEdit(edit); + const trackMentionAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (audienceScope && normalized) { + visibleAgentMentionPubkeysRef.current.add(normalized); + } + }, + [audienceScope], + ); + const syncAddressedAgentsFromText = React.useCallback( + (text: string) => { + if (!audienceScope) return; + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + for (const pubkey of visibleAgentMentionPubkeysRef.current) { + if ( + !presentAgentPubkeys.has(pubkey) && + lockedAgentPubkeys.has(pubkey) + ) { + audience.removePubkey(pubkey); + } } - return pubkey; + visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ - applyAutocompleteEdit, + audience.removePubkey, audienceScope, - mentions.cancelMentionAutocomplete, + lockedAgentPubkeys, mentions.getDraftMentionRefs, - mentions.mentionStartIndex, - richText.getPlainTextAndCursor, ], ); + const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({ }, [audience.removePubkey, audienceScope], ); + const removeAddressedAgentMentions = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); + } + removeAddressedAgent(normalized); + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.getDraftMentionRefs, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgent(pubkey); + removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { unpinnedAgentPubkeysRef.current.delete(pubkey); - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }); + const { text } = richText.getPlainTextAndCursor(); + if (getMentionOffsets(text, suggestion.displayName).length === 0) { + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: `@${suggestion.displayName} `, + preserveSelection: true, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen) { + if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { const { text, cursor } = richText.getPlainTextAndCursor(); const activeMention = detectPrefixQuery("@", text, cursor, [ suggestion.displayName.toLowerCase(), @@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({ audience.addPubkey, audienceScope, lockedAgentPubkeys, + mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, + mentions.registerMentionPubkey, + onAddressAgentMention, onPulseAddressLock, - removeAddressedAgent, + removeAddressedAgentMentions, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, ], ); @@ -209,15 +264,25 @@ 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); return; } - consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); if (!lockedAgentPubkeys.has(pubkey)) { - audience.addPubkey(pubkey); + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } else { + onPulseAddressLock(pubkey); } - onPulseAddressLock(pubkey); return; } @@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - consumeAddressSuggestion, lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, + ], + ); + + const restoreAddressedAgentMentions = React.useCallback( + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys: readonly string[] = [], + ) => { + const restorePubkeys = pubkeys + ? new Set(pubkeys.map(normalizePubkey)) + : null; + const allowedUnpinned = new Set( + allowedUnpinnedPubkeys.map(normalizePubkey), + ); + const currentAudiencePubkeys = new Set( + audience.pubkeys.map(normalizePubkey), + ); + const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)] + .filter( + (pubkey) => + currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(pubkey)?.trim() || + lockedAgentNamesRef.current.get(pubkey) || + truncatePubkey(pubkey); + return { pubkey, displayName }; + }); + const { text } = richText.getPlainTextAndCursor(); + for (const agent of targetAgents) { + if (getMentionOffsets(text, agent.displayName).length > 0) { + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + } + const missingAgents = targetAgents.filter( + (agent) => + (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || + allowedUnpinned.has(agent.pubkey)) && + getMentionOffsets(text, agent.displayName).length === 0, + ); + if (missingAgents.length === 0) return text; + for (const agent of missingAgents) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + const insertedText = `${missingAgents + .map((agent) => `@${agent.displayName}`) + .join(" ")} `; + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: true, + }); + return `${insertedText}${text}`; + }, + [ + applyAutocompleteEdit, + audience.pubkeys, + mentions.getMentionDisplayName, + mentions.registerMentionPubkey, + profiles, + richText.getPlainTextAndCursor, ], ); @@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, + trackMentionAddressedAgent, }; } diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index 8fd253df222..ed92e7b3f0f 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; export function useAlwaysAddressShortcut({ enabled, + lockedAgent, mentions, onOpenPicker, + onSelect, onToggle, }: { enabled: boolean; + lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; + onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { - const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + const { + getDefaultAgentSuggestion, + isMentionOpen, + mentionSelectedIndex, + suggestions, + } = mentions; return React.useCallback( (event: React.KeyboardEvent): boolean => { if ( !enabled || - event.key !== "Enter" || + event.code !== "KeyM" || !hasPrimaryShortcutModifier(event) || event.altKey || !event.shiftKey @@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({ event.preventDefault(); if (event.repeat) return true; - if (!isMentionOpen) { - onOpenPicker(false); + const suggestion = isMentionOpen + ? suggestions[mentionSelectedIndex] + : lockedAgent + ? { ...lockedAgent, isAgent: true } + : getDefaultAgentSuggestion(); + if (!suggestion?.isAgent || !suggestion.pubkey) { + if (!isMentionOpen) onOpenPicker(false); return true; } - - const suggestion = suggestions[mentionSelectedIndex]; - if (!suggestion?.isAgent || !suggestion.pubkey) return true; - onToggle(suggestion); + if (isMentionOpen) { + onSelect(suggestion); + } else { + onToggle(suggestion); + } return true; }, [ enabled, + getDefaultAgentSuggestion, isMentionOpen, + 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 44b87c9f1d0..d0557567fdd 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -1,36 +1,62 @@ import * as React from "react"; -import { toast } from "sonner"; import { + getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; +const CONFIRMATION_DURATION_MS = 4_000; + +type Confirmation = { + expectedRevision: number; + pubkeys: readonly string[]; + scope: string; + title: string; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; - onOpenOptions: () => void; onPulse: (pubkey: string) => void; + onTurnOff: () => void; }; export function useAutoPinMentionedAgents({ audienceScope, enabled, getDisplayName, - onOpenOptions, onPulse, + onTurnOff, }: Options) { - return React.useCallback( + const [confirmation, setConfirmation] = React.useState( + null, + ); + + React.useEffect(() => { + if (!confirmation) return; + const timeout = window.setTimeout( + () => setConfirmation(null), + CONFIRMATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [confirmation]); + + const promoteAgents = React.useCallback( ({ - expectedRevision, + expectedRevision = audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, pubkeys, + requirePreference, }: { - expectedRevision: number; + expectedRevision?: number; pubkeys: readonly string[]; + requirePreference: boolean; }) => { - if (!audienceScope || !enabled) return; + if (!audienceScope || (requirePreference && !enabled)) return; const normalizedPubkeys = [ ...new Set(pubkeys.map(normalizePubkey)), ].filter(Boolean); @@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; - toast.success(title, { - action: { - label: "Undo", - onClick: () => { - if ( - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - }) - ) { - onOpenOptions(); - } - }, - }, + setConfirmation({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + title, }); }, - [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + [audienceScope, enabled, getDisplayName, onPulse], + ); + const promoteMentionedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: true }), + [promoteAgents], + ); + const promoteExplicitlyAddressedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: false }), + [promoteAgents], ); + + const dismissConfirmation = React.useCallback( + () => setConfirmation(null), + [], + ); + const turnOffConfirmation = React.useCallback(() => { + if (!confirmation) return; + setConfirmation(null); + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: confirmation.expectedRevision, + pubkeys: confirmation.pubkeys, + scope: confirmation.scope, + }); + onTurnOff(); + }, [confirmation, onTurnOff]); + + return { + confirmationTitle: + confirmation?.scope === audienceScope ? confirmation.title : null, + dismissConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts new file mode 100644 index 00000000000..8e56da071a9 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; + +export function useComposerPasteHandler(options: { + editor: Editor | null; + scrollToBottom: () => void; + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void; + uploadFile: (file: File) => Promise; +}) { + const uploadFileRef = React.useRef(options.uploadFile); + uploadFileRef.current = options.uploadFile; + React.useEffect(() => { + const editor = options.editor; + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + handlePaste: (view, event) => { + const mediaItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.kind === "file", + ); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) void uploadFileRef.current(file); + return true; + } + const codeBlockText = getBuzzCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + options.scrollToBottom(); + return true; + } + if (handleAgentSnapshotPaste(event, options.setPendingImeta)) + return true; + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + event.preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; + } + if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + options.scrollToBottom(); + return false; + }, + }, + }); + }, [options.editor, options.scrollToBottom, options.setPendingImeta]); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 8176bb13bbd..ab7d8c2f4d6 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; - audienceRevision: number; inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { @@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = { export type SendMessageWithMentionFlowInput = { addressedAgentPubkeys?: readonly string[]; - audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 601c3b41135..10ca0332728 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -47,46 +40,8 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; - onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; - onInlineAgentMentionsSent?: (promotion: { - expectedRevision: number; - pubkeys: readonly string[]; - }) => void; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, @@ -98,8 +53,9 @@ export function useMentionSendFlow({ mentions, onPrepareSendChannel, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -406,6 +362,7 @@ export function useMentionSendFlow({ ); }; let composerCleared = false; + let optimisticComposerContent = ""; const restoreComposerAfterFailure = () => { if (!composerCleared) return; composerCleared = false; @@ -422,7 +379,7 @@ export function useMentionSendFlow({ } const canRestoreCurrentComposer = canAnimateCurrentComposer && - contentRef.current.trim().length === 0 && + contentRef.current.trim() === optimisticComposerContent.trim() && !hasUnsavedMedia(); if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { saveQueuedAttachmentsForDraft( @@ -451,6 +408,12 @@ export function useMentionSendFlow({ onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); } clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } composerCleared = true; } let uploadStarted = false; @@ -587,12 +550,23 @@ export function useMentionSendFlow({ const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), ); - onInlineAgentMentionsSent?.({ - expectedRevision: draft.audienceRevision, - pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => - sentMentionPubkeys.has(normalizePubkey(pubkey)), - ), - }); + const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( + (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), + ); + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + onAddressedAgentsSendSucceeded?.( + [ + ...new Set([ + ...draft.addressedAgentPubkeys, + ...newlyPinnedPubkeys, + ]), + ], + newlyPinnedPubkeys, + ); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -604,12 +578,18 @@ export function useMentionSendFlow({ } }; if (preparedUpload) { + let settleUpload!: () => void; + const uploadSettled = new Promise((resolve) => { + settleUpload = resolve; + }); uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { try { await finishSend(uploaded, signal); } catch { restoreComposerAfterFailure(); + } finally { + settleUpload(); } }, onError: (error) => { @@ -617,14 +597,18 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); + settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); + settleUpload(); }, }); if (!uploadStarted) { + settleUpload(); return restoreComposerAfterFailure(); } + await uploadSettled; } if (!preparedUpload) { try { @@ -657,8 +641,9 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText.setContent, @@ -674,7 +659,6 @@ export function useMentionSendFlow({ const sendMessageWithMentionFlow = React.useCallback( async ({ addressedAgentPubkeys = [], - audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -783,7 +767,6 @@ export function useMentionSendFlow({ const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - audienceRevision, inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( savedMentionRefs .filter((ref) => ref.isAgent) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 00000000000..496a73a94a7 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,52 @@ +import type * as React from "react"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { ChannelType } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; + onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendSucceeded?: ( + pubkeys: readonly string[], + newlyPinnedPubkeys: readonly string[], + ) => void; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; +}; diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index e8b388d0dcb..d8e1550549f 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,9 +166,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Open the agent picker, or toggle the highlighted agent", - keys: "⇧⌘↵", - keysWindows: "Ctrl+Shift+Enter", + description: "Address the default agent, or select the highlighted agent", + keys: "⇧⌘M", + keysWindows: "Ctrl+Shift+M", category: "Messages", }, { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6726a224024..2136e2e12f5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect .poll(() => readOutgoingMentionPubkeys(page, "local")) .toEqual([managedPubkey]); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@carl "); await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); @@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({ expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@alice "); await expect(threadPanel).toContainText("before members resolve"); }); @@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray" await expect(dropdown.getByText("agent")).toBeVisible(); await input.press("Enter"); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); @@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await expect(input.locator(".mention-chip")).toHaveCount(0); await input.press("Enter"); - await expect(input).toBeEmpty(); - await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index be1dbc86c3f..f9481c91f05 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -28,6 +28,9 @@ async function automaticallyMention( .getByTestId("mention-autocomplete") .getByRole("button", { name: `Automatically mention ${displayName}` }) .click(); + await expect(composer.getByTestId("message-input")).toContainText( + `@${displayName}`, + ); await composer.locator("[data-mention-picker-trigger]").click(); } @@ -54,7 +57,20 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } -async function pressPrimaryShift(page: Page, key: "Enter" | "M") { +async function readComposerCaret(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return range.toString().length; + }); +} + +async function pressPrimaryShift(page: Page, key: "M") { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); @@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +async function emitMockMessage( + page: Page, + content: string, + mentionPubkeys: string[], +) { + await page.evaluate( + ({ body, mentions }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + mentionPubkeys: mentions, + }); + }, + { body: content, mentions: mentionPubkeys }, + ); +} + async function installAudienceFixtures( page: Page, options: { + deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; + uploadDelayMs?: number; + uploadDescriptors?: Array<{ + filename: string; + sha256: string; + size: number; + type: string; + uploaded: number; + url: string; + }>; usersBatchDelayMs?: number; } = {}, ) { @@ -133,6 +176,80 @@ async function installAudienceFixtures( }); } +test("keeps a queued-attachment send locked through upload and send settlement", async ({ + page, +}) => { + await installAudienceFixtures(page, { + deferredComposerUploads: true, + uploadDelayMs: 2_000, + sendMessageDelayMs: 2_000, + uploadDescriptors: [ + { + filename: "delayed-video.mp4", + sha256: "d".repeat(64), + size: 16, + type: "video/mp4", + uploaded: 1, + url: `https://mock.relay/media/${"d".repeat(64)}.mp4`, + }, + ], + }); + await openGeneral(page); + + const composer = channelComposer(page); + const composerForm = composer.getByTestId("message-composer"); + const input = composer.getByTestId("message-input"); + await input.fill("delayed upload"); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("delayed video"), + mimeType: "video/mp4", + name: "delayed-video.mp4", + }); + + const sendAttempts = () => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "send_channel_message", + ).length ?? 0, + ); + const sendAttemptsBefore = await sendAttempts(); + await input.press("Enter"); + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + + // Observe the synchronous lock itself after upload has started. The exact + // premature-release mutation (`void uploadSettled; await Promise.resolve()`) + // has already cleared this attribute by this boundary, before any retryable + // downstream command or restored-audience timing can obscure the defect. + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1); + + // Command entry marks the independently delayed finishSend() window. Keep + // the synchronous lock held and fence repeated submits until it settles. + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect( + page + .getByTestId("message-row") + .filter({ hasText: "delayed upload" }) + .last(), + ).toBeVisible({ timeout: 5_000 }); + await expect(composerForm).toHaveAttribute("data-submit-locked", "false"); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { @@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab keeps a manually selected agent as an inline mention", async ({ - page, -}) => { +test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({ await expect(input).toHaveText("@Morgarita "); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + 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); }); -test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { await installAudienceFixtures(page); @@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - const menu = composer.getByTestId("mention-autocomplete"); - await expect(menu).toBeVisible(); + 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 expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); - await input.fill("@Mor"); - await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass( + await input.fill("@Vog"); + const menu = composer.getByTestId("mention-autocomplete"); + await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - await expect(menu).toBeVisible(); - await expect(input).toHaveText(""); + await expect(menu).toHaveCount(0); + await expect(input).toHaveText("@Vogue "); await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); +}); + +test("primary+Shift+M favors the most recently mentioned eligible agent", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); + + const input = channelComposer(page).getByTestId("message-input"); + await input.fill("draft text"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await expect.poll(() => readComposerCaret(input)).toBe(8); + await pressPrimaryShift(page, "M"); + + await expect(input).toHaveText("@Vogue draft text"); + await expect.poll(() => readComposerCaret(input)).toBe(15); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@Vogue draft text"); }); test("the mention button opens settings and can undo an address", async ({ @@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({ name: "Manage automatic agent mentions", }); - await input.fill("draft text"); + await input.type("draft text"); await ingress.click(); const menu = composer.getByTestId("mention-autocomplete"); await expect(menu).toBeVisible(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await page.getByTestId("mention-options-trigger").click(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), @@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({ if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); await expect(menu).toHaveCount(0); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( @@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({ ).toHaveCount(0); await ingress.click(); await expect(menu).toHaveCount(0); - await input.fill(""); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); @@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({ await menu .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) .click(); - await expect(input).toHaveText(""); + await expect(input).toHaveText("draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); + await input.fill(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) @@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({ await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText(""); + await expect(input).toHaveText("@Morgarita "); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); @@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); - const send = composer.getByTestId("send-message"); + await input.evaluate((element) => { + const snapshots = [element.textContent ?? ""]; + new MutationObserver(() => + snapshots.push(element.textContent ?? ""), + ).observe(element, { childList: true, characterData: true, subtree: true }); + ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots; + }); const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`); const initialPulseVersion = Number( await avatar.getAttribute("data-pulse-version"), ); - await input.fill("hello"); + await input.type("hello"); + await input.evaluate((element) => { + const snapshots = ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__; + snapshots?.splice(0, snapshots.length, element.textContent ?? ""); + }); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); + await expect(input).toHaveText("@Morgarita ", { timeout: 500 }); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[]; + } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [], + ), + ) + .not.toContain(""); await expect(avatar).toHaveAttribute( "data-pulse-version", String(initialPulseVersion + 1), @@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await expect(send).toBeDisabled(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "hello")) + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveText("@Morgarita "); const sentRow = page .getByTestId("message-row") @@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso ); await expect(addressPrefix).toBeVisible(); await expect(addressPrefix).toHaveText("Morgarita"); + + await input.type("follow up"); + await input.press("Enter"); + const inlineMentionRow = page + .getByTestId("message-row") + .filter({ hasText: "follow up" }) + .last(); + await expect( + inlineMentionRow.locator("[data-mention].agent-mention-highlight", { + hasText: "Morgarita", + }), + ).toHaveCount(1); }); test("a failed always-mentioned send shakes the composer avatar", async ({ @@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ ); await expect(avatar).toHaveAttribute("data-shake-version", "0"); - await input.fill("please retry"); + await input.type("please retry"); await input.press("Enter"); await expect(avatar).toHaveAttribute( @@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ String(initialPulseVersion + 1), { timeout: 500 }, ); - await expect(input).toHaveText("please retry"); + await expect(input).toHaveText("@Morgarita please retry"); await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected after the message sends", async ({ +test("a manually mentioned agent becomes selected immediately", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await expect(autoPinConfirmation).not.toContainText( + "Future messages in this channel will include this agent.", + ); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation.locator("span")).toHaveCSS( + "white-space", + "nowrap", + ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Morgarita will be mentioned automatically" }), ).toHaveCount(0); + const addressControlBox = await composer + .getByTestId("composer-address-locks") + .locator("..") + .boundingBox(); + const confirmationBox = await autoPinConfirmation.boundingBox(); + expect(addressControlBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressControlBox || !confirmationBox) { + throw new Error("Automatic mention confirmation is not laid out"); + } + expect(confirmationBox.x).toBeGreaterThan( + addressControlBox.x + addressControlBox.width, + ); + const turnOffAction = autoPinConfirmation.getByRole("button", { + name: "Turn off", + }); + await expect(turnOffAction).toHaveRole("button"); + await expect(turnOffAction).toHaveText("Turn off"); + + await input.press("Escape"); + await expect(autoPinConfirmation).toHaveCount(0); await input.type("hello"); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); - await expect(input.locator("[data-placeholder]").first()).toHaveAttribute( - "data-placeholder", - "Message #general", - { timeout: 500 }, - ); + await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 }); + await expect(input.locator("[data-placeholder]")).toHaveCount(0); await expect(input).toBeFocused(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible({ timeout: 2_500 }); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await expect(autoPinToast).not.toContainText( - "Future messages in this channel will include this agent.", - ); - const undoAction = autoPinToast.locator("[data-action]"); - await expect(undoAction).toHaveRole("button"); - await expect(undoAction).toHaveText("Undo"); + await expect(autoPinConfirmation).toHaveCount(0); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); await input.fill("follow up"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); await input.press("Enter"); await expect .poll(() => readOutgoingMentionPubkeys(page, "follow up")) - .toContain(AGENT_A); + .not.toContain(AGENT_A); }); -test("the auto-pin toast can undo and the picker can restore the agent", async ({ +test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { await installAudienceFixtures(page); @@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async ( await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); - await input.press("Space"); - await input.type("undo me"); - await input.press("Enter"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await autoPinToast.locator("[data-action]").click(); + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); + + await composer.getByTestId("message-insert-mention").click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toBeVisible(); + ).toHaveAttribute("data-state", "unchecked"); await input.press("Escape"); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await composer.locator("[data-mention-picker-trigger]").click(); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Mention Morgarita", exact: true }) - .click(); - await expect(input).toHaveText(""); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); }); test("channel automatic mentions carry into threads and stay synchronized", async ({ @@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn await expect(threadAutomaticMention).toBeVisible(); await threadComposer(page) - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); await expect(threadAutomaticMention).toHaveCount(0); await expect(channelAutomaticMention).toHaveCount(0); }); +test("reduced motion removes addressed agents without spatial animation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + 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 input.press("Tab"); + const removeButton = composer.getByTestId( + `composer-address-lock-remove-${AGENT_A}`, + ); + await expect(removeButton).toBeVisible(); + await expect(removeButton).toHaveAttribute("style", /opacity: 1/); + await expect(removeButton).toHaveCSS("transform", "none"); + + await removeButton.click(); + await expect(input).toHaveText("@Morgarita "); + await expect(removeButton).toHaveCount(0); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures the mention-button placement in ${theme}`, async ({ page, @@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({ await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); }); + +test("captures the lightweight auto-pin popover", async ({ page }) => { + await seedTheme(page, "buzz-dark"); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + + const addressControl = composer + .getByTestId("composer-address-locks") + .locator(".."); + const confirmation = page.getByTestId("composer-auto-pin-confirmation"); + await expect(confirmation).toContainText( + "alice will be mentioned automatically", + ); + await waitForAnimations(page); + + const addressBox = await addressControl.boundingBox(); + const confirmationBox = await confirmation.boundingBox(); + expect(addressBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressBox || !confirmationBox) { + throw new Error("Popover is not laid out"); + } + + const left = addressBox.x - 14; + const top = Math.min(addressBox.y, confirmationBox.y) - 14; + const right = confirmationBox.x + confirmationBox.width + 14; + const bottom = + Math.max( + addressBox.y + addressBox.height, + confirmationBox.y + confirmationBox.height, + ) + 14; + await page.screenshot({ + path: `${SHOTS}/auto-pin-popover-dark.png`, + clip: { x: left, y: top, width: right - left, height: bottom - top }, + }); +}); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index b0edbf66302..3a7fdf7613a 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,8 +84,9 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the agent is addressed before submitting. - await expect(input).toHaveText(` ${MESSAGE_TEXT}`); + // Verify the inline mention and persistent address are present before submitting. + await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); + await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), ).toBeVisible();