From d4a0ea6b22d7a3531b581904d920981cafe7fb85 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 26 Aug 2026 17:27:02 -0700 Subject: [PATCH 1/2] revert(desktop): restore previous agent mention behavior Remove the composer auto-mention and persistent addressing stack while preserving unrelated draft and GIF work that landed afterward. Co-authored-by: Carl Signed-off-by: Taylor Ho --- desktop/src-tauri/src/events/message_tags.rs | 46 +- .../src/features/channels/ui/ChannelPane.tsx | 21 +- .../channels/ui/useChannelPaneMessages.ts | 12 +- .../features/communities/useCommunityInit.ts | 2 - .../src/features/forum/ui/ForumComposer.tsx | 1 - .../forum/ui/ForumComposerAutocompletes.tsx | 3 - .../src/features/home/ui/InboxDetailPane.tsx | 47 +- .../messages/lib/agentAddressMention.d.mts | 12 - .../messages/lib/agentAddressMention.mjs | 39 - .../messages/lib/agentAddressMention.test.mjs | 30 - .../messages/lib/applyEditTagOverlay.mjs | 16 +- .../messages/lib/applyEditTagOverlay.test.mjs | 21 - .../autoPinMentionedAgentsPreference.test.mjs | 33 - .../lib/autoPinMentionedAgentsPreference.ts | 59 -- .../messages/lib/extractMentionPersonas.ts | 26 - .../getVisibleAgentAddressPubkeys.test.mjs | 39 - .../lib/getVisibleAgentAddressPubkeys.ts | 20 - .../messages/lib/mentionCandidates.ts | 1 - .../messages/lib/mentionHighlightExtension.ts | 3 +- .../messages/lib/mentionRanking.test.mjs | 110 +-- .../features/messages/lib/mentionRanking.ts | 53 - .../messages/lib/mentionSuggestionMapping.ts | 27 +- .../lib/persistentAgentAudience.test.mjs | 358 +++++-- .../messages/lib/persistentAgentAudience.ts | 211 ++-- .../lib/recentMentionPubkeys.test.mjs | 114 --- .../messages/lib/recentMentionPubkeys.ts | 41 - .../messages/lib/useActiveAgentPubkeys.ts | 24 - .../messages/lib/useDefaultAgentSuggestion.ts | 50 - .../messages/lib/useMentionSelection.ts | 40 - .../src/features/messages/lib/useMentions.ts | 197 ++-- .../messages/lib/useRichTextEditor.ts | 29 +- .../ui/ComposerAddressControls.test.mjs | 130 --- .../messages/ui/ComposerAddressControls.tsx | 341 ------- .../messages/ui/MentionAutocomplete.test.mjs | 314 +----- .../messages/ui/MentionAutocomplete.tsx | 478 +++------ .../messages/ui/MessageAgentAddressPrefix.tsx | 47 - .../features/messages/ui/MessageComposer.tsx | 388 ++++---- .../messages/ui/MessageComposer.types.ts | 6 +- .../messages/ui/MessageComposerToolbar.tsx | 75 +- .../src/features/messages/ui/MessageRow.tsx | 19 +- .../messages/ui/MessageThreadPanel.tsx | 36 +- .../ui/composerAgentKeyboard.test.mjs | 194 ---- .../ui/persistentAgentAudienceHosts.test.mjs | 16 +- .../ui/useAddressMentionPulse.test.mjs | 50 - .../messages/ui/useAddressMentionPulse.ts | 44 - .../ui/useAgentAddressLockPicker.test.mjs | 634 ------------ .../messages/ui/useAgentAddressLockPicker.ts | 388 -------- .../messages/ui/useAlwaysAddressShortcut.ts | 70 -- .../messages/ui/useAutoPinMentionedAgents.ts | 124 --- .../messages/ui/useComposerMentionPicker.ts | 63 -- .../messages/ui/useComposerPasteHandler.ts | 73 -- .../ui/useMentionSendFlow.helpers.test.mjs | 15 - .../messages/ui/useMentionSendFlow.helpers.ts | 18 +- .../messages/ui/useMentionSendFlow.ts | 380 ++++---- .../messages/ui/useMentionSendFlow.types.ts | 51 - .../ui/usePersistentAgentMentionHydration.ts | 175 ++++ .../settings/ui/AgentsSettingsPanel.tsx | 37 +- .../settings/ui/PreventSleepSettingsCard.tsx | 29 + desktop/src/shared/lib/keyboard-shortcuts.ts | 8 - .../shared/ui/VideoReviewCommentMarkdown.tsx | 31 +- desktop/tests/e2e/mentions.spec.ts | 110 +-- .../e2e/persistent-agent-audience.spec.ts | 914 ++++-------------- desktop/tests/e2e/profile.spec.ts | 5 +- .../tests/e2e/send-channel-binding.spec.ts | 11 +- 64 files changed, 1641 insertions(+), 5318 deletions(-) delete mode 100644 desktop/src/features/messages/lib/agentAddressMention.d.mts delete mode 100644 desktop/src/features/messages/lib/agentAddressMention.mjs delete mode 100644 desktop/src/features/messages/lib/agentAddressMention.test.mjs delete mode 100644 desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs delete mode 100644 desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts delete mode 100644 desktop/src/features/messages/lib/extractMentionPersonas.ts delete mode 100644 desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs delete mode 100644 desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts delete mode 100644 desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs delete mode 100644 desktop/src/features/messages/lib/recentMentionPubkeys.ts delete mode 100644 desktop/src/features/messages/lib/useActiveAgentPubkeys.ts delete mode 100644 desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts delete mode 100644 desktop/src/features/messages/lib/useMentionSelection.ts delete mode 100644 desktop/src/features/messages/ui/ComposerAddressControls.test.mjs delete mode 100644 desktop/src/features/messages/ui/ComposerAddressControls.tsx delete mode 100644 desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx delete mode 100644 desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs delete mode 100644 desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs delete mode 100644 desktop/src/features/messages/ui/useAddressMentionPulse.ts delete mode 100644 desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs delete mode 100644 desktop/src/features/messages/ui/useAgentAddressLockPicker.ts delete mode 100644 desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts delete mode 100644 desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts delete mode 100644 desktop/src/features/messages/ui/useComposerMentionPicker.ts delete mode 100644 desktop/src/features/messages/ui/useComposerPasteHandler.ts delete mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs delete mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.types.ts create mode 100644 desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs index 1d719beaa66..c43a8874def 100644 --- a/desktop/src-tauri/src/events/message_tags.rs +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -4,7 +4,6 @@ use super::check_pubkey; const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; -const AGENT_ADDRESS_MENTION_MARKER: &str = "agent-address"; pub(super) fn mention_reference_tags( mentions: &[Vec], @@ -20,20 +19,10 @@ pub(super) fn mention_reference_tags( let Some(pubkey) = mention.get(1) else { return Err("mention reference tag missing pubkey".into()); }; - if mention.len() > 3 - || (mention.len() == 3 - && mention.get(2).map(String::as_str) != Some(AGENT_ADDRESS_MENTION_MARKER)) - { - return Err("mention reference tag has invalid display metadata".into()); - } check_pubkey(pubkey)?; - let normalized_pubkey = pubkey.to_ascii_lowercase(); - let mut parts = vec!["mention", normalized_pubkey.as_str()]; - if mention.len() == 3 { - parts.push(AGENT_ADDRESS_MENTION_MARKER); - } tags.push( - Tag::parse(parts).map_err(|error| format!("invalid mention reference tag: {error}"))?, + Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid mention reference tag: {error}"))?, ); } Ok(()) @@ -126,39 +115,8 @@ pub(super) fn append_client_tags( mod tests { use super::*; - const PUBKEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; - #[test] - fn mention_reference_preserves_agent_address_display_metadata() { - let mut tags = Vec::new(); - mention_reference_tags( - &[vec![ - "mention".into(), - PUBKEY.to_ascii_uppercase(), - AGENT_ADDRESS_MENTION_MARKER.into(), - ]], - &mut tags, - ) - .unwrap(); - - assert_eq!( - tags[0].as_slice(), - &["mention", PUBKEY, AGENT_ADDRESS_MENTION_MARKER] - ); - } - - #[test] - fn mention_reference_rejects_unknown_display_metadata() { - let mut tags = Vec::new(); - let result = mention_reference_tags( - &[vec!["mention".into(), PUBKEY.into(), "unknown".into()]], - &mut tags, - ); - - assert!(result.is_err()); - } - #[test] fn message_accepts_only_valid_sent_from_thread_provenance() { let source_tag = vec![ diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a4..1185a4d45c8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -357,6 +357,7 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [activeChannel, currentPubkey, profiles], ); + const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -373,14 +374,13 @@ export const ChannelPane = React.memo(function ChannelPane({ onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); const channelIntro = isHuddleTranscript ? null : standardChannelIntro; - const { mainTimelineEntries, recentMentions, visibleMessages } = - useChannelPaneMessages({ - activeChannel, - isHuddleTranscript, - messages, - profiles, - threadSummaries, - }); + const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -753,7 +753,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} getRecentMentionPubkeys(messages, activeChannel?.channelType), - [activeChannel?.channelType, messages], - ); - - return { - mainTimelineEntries, - recentMentions: recentMentionPubkeys, - visibleMessages, - }; + return { mainTimelineEntries, visibleMessages }; } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b4..3e8b9008367 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -23,7 +23,6 @@ import { import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; -import { resetPersistentAgentAudienceStore } from "@/features/messages/lib/persistentAgentAudience"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -77,7 +76,6 @@ async function resetCommunityState({ resetRenderScopedReactionHydration(); resetBackgroundMediaUploads(); resetLinkPreviewPreparations(); - resetPersistentAgentAudienceStore(); clearSearchHitEventCache(); clearMarkdownNodeCache(); resetMessageLinkMetadataCache(); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 961716aaf48..6193af4bbbb 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -527,7 +527,6 @@ export function ForumComposer({ mentions.isMentionOpen ? mentions.suggestions : [] } onChannelSelect={applyChannelInsert} - onMentionDismiss={mentions.cancelMentionAutocomplete} onMentionFetchMore={mentions.fetchMoreSuggestions} onMentionSelect={applyMentionInsert} position={autocompletePosition} diff --git a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx index 149eec7b91c..5608a4e909d 100644 --- a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx +++ b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx @@ -12,7 +12,6 @@ type ForumComposerAutocompletesProps = { mentionSuggestions: MentionSuggestion[]; onChannelSelect: (suggestion: ChannelSuggestion) => void; onMentionFetchMore?: () => void; - onMentionDismiss: () => void; onMentionSelect: (suggestion: MentionSuggestion) => void; position: "above" | "below"; }; @@ -24,7 +23,6 @@ export function ForumComposerAutocompletes({ mentionSuggestions, onChannelSelect, onMentionFetchMore, - onMentionDismiss, onMentionSelect, position, }: ForumComposerAutocompletesProps) { @@ -37,7 +35,6 @@ export function ForumComposerAutocompletes({ suggestions={channelSuggestions} /> message.id === conversationId); + const feedRoot = item + ? [item.item, ...item.groupItems].find( + (groupItem) => groupItem.id === conversationId, + ) + : undefined; + const rootMessage = contextRoot + ? { + authorPubkey: contextRoot.authorPubkey, + content: contextRoot.content, + mentionPubkeysByName: contextRoot.mentionPubkeysByName, + } + : feedRoot && profiles + ? { + authorPubkey: feedRoot.pubkey, + content: feedRoot.content, + mentionPubkeysByName: resolveMentionProps(feedRoot.tags, profiles) + .mentionPubkeysByName, + } + : null; + const initialAgentPubkeys = rootMessage + ? currentPubkey && + normalizePubkey(rootMessage.authorPubkey) === + normalizePubkey(currentPubkey) + ? orderMentionPubkeysByText( + rootMessage.content, + rootMessage.mentionPubkeysByName, + (pubkey) => agentPubkeys?.has(pubkey) === true, + ) + : [] + : undefined; const displayMessages = React.useMemo(() => { const selectedMessage = messages.find((message) => message.isSelected); const pendingReplyMessages: InboxDisplayMessage[] = replies.map( @@ -804,7 +841,15 @@ function InboxMessageDetailPane({ />
, - deliveredPubkeys: Iterable, -): string[][]; - -export function getAgentAddressMentionPubkeys( - tags: readonly (readonly string[])[] | null | undefined, -): string[]; - -export function isAgentAddressMentionTag(tag: readonly string[]): boolean; diff --git a/desktop/src/features/messages/lib/agentAddressMention.mjs b/desktop/src/features/messages/lib/agentAddressMention.mjs deleted file mode 100644 index c1297e76125..00000000000 --- a/desktop/src/features/messages/lib/agentAddressMention.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { normalizePubkey } from "../../../shared/lib/pubkey.ts"; - -export const AGENT_ADDRESS_MENTION_MARKER = "agent-address"; - -/** - * Persist the subset of delivered mentions that came from the composer's - * address tray. The ordinary `p` tag remains the notification mechanism; - * this annotated reference is display metadata for reconstructing the tray - * state when the message is rendered later. - */ -export function buildAgentAddressMentionTags( - addressedPubkeys, - deliveredPubkeys, -) { - const delivered = new Set([...deliveredPubkeys].map(normalizePubkey)); - return [...new Set([...addressedPubkeys].map(normalizePubkey))] - .filter((pubkey) => pubkey && delivered.has(pubkey)) - .map((pubkey) => ["mention", pubkey, AGENT_ADDRESS_MENTION_MARKER]); -} - -/** Return the ordered, deduplicated agent-address recipients on an event. */ -export function getAgentAddressMentionPubkeys(tags) { - return [ - ...new Set( - (tags ?? []) - .filter( - (tag) => - tag[0] === "mention" && - tag[2] === AGENT_ADDRESS_MENTION_MARKER && - Boolean(tag[1]), - ) - .map((tag) => normalizePubkey(tag[1])), - ), - ]; -} - -export function isAgentAddressMentionTag(tag) { - return tag[0] === "mention" && tag[2] === AGENT_ADDRESS_MENTION_MARKER; -} diff --git a/desktop/src/features/messages/lib/agentAddressMention.test.mjs b/desktop/src/features/messages/lib/agentAddressMention.test.mjs deleted file mode 100644 index cb239c3c2e3..00000000000 --- a/desktop/src/features/messages/lib/agentAddressMention.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - AGENT_ADDRESS_MENTION_MARKER, - buildAgentAddressMentionTags, - getAgentAddressMentionPubkeys, -} from "./agentAddressMention.mjs"; - -const ALICE = "a".repeat(64); -const BOB = "b".repeat(64); - -test("builds address metadata only for recipients that survived admission", () => { - assert.deepEqual( - buildAgentAddressMentionTags([ALICE.toUpperCase(), BOB], [ALICE]), - [["mention", ALICE, AGENT_ADDRESS_MENTION_MARKER]], - ); -}); - -test("reads ordered address metadata without treating ordinary mentions as tray state", () => { - assert.deepEqual( - getAgentAddressMentionPubkeys([ - ["p", BOB], - ["mention", BOB], - ["mention", ALICE.toUpperCase(), AGENT_ADDRESS_MENTION_MARKER], - ["mention", ALICE, AGENT_ADDRESS_MENTION_MARKER], - ]), - [ALICE], - ); -}); diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs index 2abffa12b4f..809dcac3bd4 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -9,8 +9,6 @@ * TypeScript-facing callers get typed access via the sibling `.d.mts`. */ -import { isAgentAddressMentionTag } from "./agentAddressMention.mjs"; - /** * Merge the original event's tags with an edit's tags so that: * - `imeta` tags come exclusively from the edit (full new attachment set); @@ -19,8 +17,7 @@ import { isAgentAddressMentionTag } from "./agentAddressMention.mjs"; * snapshot from the edited composer (marked by `buzz:mention-snapshot`) * and therefore replace the original set; this preserves the edited body's * stable recipient identities even before profiles load or after an alias - * changes. Agent-address mention metadata describes immutable send-time - * state, so it survives that authored-mention snapshot; + * changes; * - `emoji` (NIP-30 custom-emoji) tags come from the edit *when the edit * supplies any* — the edited body may add or remove custom emoji, so a * supplied set rebuilds the shortcode→url map. But when the edit supplies @@ -42,9 +39,7 @@ export function applyEditTagOverlay(originalTags, editTags) { const hasMentionSnapshot = editTags.some( (t) => t[0] === "buzz:mention-snapshot", ); - const editMentions = editTags.filter( - (t) => t[0] === "mention" && !isAgentAddressMentionTag(t), - ); + const editMentions = editTags.filter((t) => t[0] === "mention"); // imeta is always fully replaced by the edit. emoji is replaced only when // the edit actually supplies emoji tags; otherwise the original's are kept. // An edit carrying the private snapshot marker is authoritative, including @@ -53,12 +48,7 @@ export function applyEditTagOverlay(originalTags, editTags) { const droppedFromOriginal = (tag) => { if (tag[0] === "imeta") return false; if (editEmoji.length > 0 && tag[0] === "emoji") return false; - if ( - hasMentionSnapshot && - tag[0] === "mention" && - !isAgentAddressMentionTag(tag) - ) - return false; + if (hasMentionSnapshot && tag[0] === "mention") return false; return true; }; const baseFromOriginal = originalTags.filter(droppedFromOriginal); diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs index 40cca2f858d..d77bf9d940a 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -153,27 +153,6 @@ test("edit mention snapshot replaces original references, including removals", ( ); }); -test("edit mention snapshots preserve the original agent-address state", () => { - const original = [ - ["h", "uuid"], - ["mention", "addressed-agent", "agent-address"], - ["mention", "old-authored-mention"], - ]; - const out = applyEditTagOverlay(original, [ - ["buzz:mention-snapshot"], - ["mention", "addressed-agent", "agent-address"], - ["mention", "new-authored-mention"], - ]); - - assert.deepEqual( - out.filter((tag) => tag[0] === "mention"), - [ - ["mention", "addressed-agent", "agent-address"], - ["mention", "new-authored-mention"], - ], - ); -}); - test("legacy edits preserve original mention references", () => { const original = [ ["h", "uuid"], diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs deleted file mode 100644 index b30008109c1..00000000000 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -const values = new Map(); -globalThis.localStorage = { - getItem: (key) => values.get(key) ?? null, - setItem: (key, value) => values.set(key, String(value)), -}; - -const preference = await import("./autoPinMentionedAgentsPreference.ts"); - -test("defaults missing and invalid values to one-time agent mentions", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); - assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); -}); - -test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(true); - assert.equal(preference.getKeepMentionedAgentsPinned(), true); - assert.equal( - values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "true", - ); - - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); - assert.equal( - values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", - ); -}); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts deleted file mode 100644 index 8f8e0b12d65..00000000000 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react"; - -export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = - "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; - -const listeners = new Set<() => void>(); -let keepMentionedAgentsPinned = readStoredPreference(); - -export function parseKeepMentionedAgentsPinned( - value: string | null | undefined, -): boolean { - if (value === "false") return false; - if (value === "true") return true; - return DEFAULT_KEEP_MENTIONED_AGENTS_PINNED; -} - -function readStoredPreference(): boolean { - try { - return parseKeepMentionedAgentsPinned( - globalThis.localStorage?.getItem( - KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY, - ), - ); - } catch { - return DEFAULT_KEEP_MENTIONED_AGENTS_PINNED; - } -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} - -export function getKeepMentionedAgentsPinned(): boolean { - return keepMentionedAgentsPinned; -} - -export function setKeepMentionedAgentsPinned(value: boolean): void { - if (value === keepMentionedAgentsPinned) return; - keepMentionedAgentsPinned = value; - try { - globalThis.localStorage?.setItem( - KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY, - String(value), - ); - } catch { - // Persistence is best-effort; the live preference still applies. - } - for (const listener of listeners) listener(); -} - -export function useKeepMentionedAgentsPinned(): boolean { - return React.useSyncExternalStore( - subscribe, - getKeepMentionedAgentsPinned, - () => DEFAULT_KEEP_MENTIONED_AGENTS_PINNED, - ); -} diff --git a/desktop/src/features/messages/lib/extractMentionPersonas.ts b/desktop/src/features/messages/lib/extractMentionPersonas.ts deleted file mode 100644 index a65f9123c49..00000000000 --- a/desktop/src/features/messages/lib/extractMentionPersonas.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AgentPersona } from "@/shared/api/types"; -import { hasMention } from "./hasMention"; - -export type PersonaMentionTarget = { - displayName: string; - persona: AgentPersona; -}; - -export function extractMentionPersonasFromMaps( - text: string, - personaMentions: ReadonlyMap, - activePersonaById: ReadonlyMap, -): PersonaMentionTarget[] { - const targets: PersonaMentionTarget[] = []; - const seen = new Set(); - - for (const [displayName, personaId] of personaMentions) { - if (seen.has(personaId) || !hasMention(text, displayName)) continue; - const persona = activePersonaById.get(personaId); - if (!persona) continue; - targets.push({ displayName, persona }); - seen.add(personaId); - } - - return targets; -} diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs deleted file mode 100644 index ad00eb296c7..00000000000 --- a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index f96279628f1..00000000000 --- a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 659832deadf..3ad358a0d66 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -46,7 +46,6 @@ 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 e55c79c9a38..f00e21cfe45 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -249,7 +249,6 @@ export function settleAutocompleteMentionInsert( editor: { storage: object }, tr: Transaction, text: string, - settleCaret = true, ): void { const storage = mentionHighlightStorage(editor); const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text); @@ -270,7 +269,7 @@ export function settleAutocompleteMentionInsert( } } } - if (settleCaret) tr.setMeta(mentionHighlightKey, true); + 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 bdbf1cf11d2..2e74bba52d4 100644 --- a/desktop/src/features/messages/lib/mentionRanking.test.mjs +++ b/desktop/src/features/messages/lib/mentionRanking.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDefaultAgentCandidate, - rankMentionCandidates, -} from "./mentionRanking.ts"; +import { rankMentionCandidates } from "./mentionRanking.ts"; const CHANNEL_BRAIN_PUBKEY = "1".repeat(64); const OTHER_BRAIN_PUBKEY = "2".repeat(64); @@ -142,108 +139,3 @@ 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 3df5bba0b0a..09b9e03de7b 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -3,7 +3,6 @@ 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; @@ -52,58 +51,6 @@ 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 cd84aa6e8c4..9be4f2c4a57 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -3,9 +3,7 @@ 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 { MentionCandidate, TeamMentionMember } from "./mentionCandidates"; -import { mentionCandidateLabel } from "./mentionCandidates"; -import { pickDefaultAgentCandidate } from "./mentionRanking"; +import type { TeamMentionMember } from "./mentionCandidates"; export type MentionSuggestionCandidate = { kind: "identity" | "persona" | "team"; @@ -76,26 +74,3 @@ 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/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index 64d11cd6312..b76d8e3c51c 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -1,39 +1,63 @@ import assert from "node:assert/strict"; import test from "node:test"; +function createStorage(onSetItem = () => {}, initialValues = []) { + const values = new Map(initialValues); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + onSetItem(key, value); + values.set(key, String(value)); + }, + }; +} + const agentA = "a".repeat(64); const agentB = "b".repeat(64); const agentC = "c".repeat(64); const ownerA = "1".repeat(64); const ownerB = "2".repeat(64); +const enabledStorageKey = "buzz:keep-addressed-agents-active:v2"; +const audienceStorageKey = "buzz:persistent-agent-audiences:v3"; +const legacyEnabledStorageKey = "buzz:keep-addressed-agents-active"; +const legacyAudienceStorageKey = "buzz:persistent-agent-audiences:v2"; let loadSequence = 0; -async function loadStore(offset = 0) { +async function loadStore(offset = 0, initialValues = []) { + globalThis.window = { localStorage: createStorage(() => {}, initialValues) }; loadSequence += 1; return import( `./persistentAgentAudience.ts?test=${Date.now()}-${offset}-${loadSequence}` ); } -function currentAudiences(store) { - return store.getPersistentAgentAudienceSnapshot().audiences; +function savedAudiences() { + return JSON.parse(window.localStorage.getItem(audienceStorageKey)); } -test("audience scopes isolate identities and channels", async () => { +test("conversation scopes isolate identities, channels, and threads", async () => { const store = await loadStore(); const scopes = [ store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", + threadRootId: "root-1", + }), + store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + threadRootId: "root-2", }), store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-b", + threadRootId: "root-1", }), store.getPersistentAgentAudienceScope({ ownerPubkey: ownerB, channelId: "channel-a", + threadRootId: "root-1", }), ]; @@ -42,173 +66,303 @@ test("audience scopes isolate identities and channels", async () => { store.setPersistentAgentAudience(scope, [agentA]); } - assert.equal(new Set(Object.keys(currentAudiences(store))).size, 3); + assert.equal(new Set(Object.keys(savedAudiences())).size, 4); }); -test("address locks can be added independently", async () => { +test("successful fast send promotes without a persisted draft key", async () => { const store = await loadStore(1); const scope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", + threadRootId: "root", }); - store.addPersistentAgentAudienceMember(scope, agentA); - store.addPersistentAgentAudienceMember(scope, agentB); + store.setPersistentAgentAudienceEnabled(true); - assert.deepEqual(currentAudiences(store), { [scope]: [agentA, agentB] }); + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA] }); }); -test("adding an existing address lock preserves order and dedupes", async () => { +test("explicit recipients merge and dedupe after successful send", async () => { const store = await loadStore(2); - const scope = `${ownerA}:channel-a:channel`; + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revision = store.getPersistentAgentAudienceRevision(scope); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revision, + explicitAgentPubkeys: [agentA, agentB], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA, agentB] }); +}); + +test("successful send makes authored mention order authoritative", async () => { + const store = await loadStore(100); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); store.setPersistentAgentAudience(scope, [agentA, agentB]); - store.addPersistentAgentAudienceMember(scope, agentA); - assert.deepEqual(currentAudiences(store), { [scope]: [agentA, agentB] }); + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA, agentC], + }); + + assert.deepEqual(savedAudiences(), { + [scope]: [agentB, agentA, agentC], + }); }); -test("address locks can be removed independently", async () => { +test("successful send retains saved targets absent from the draft", async () => { + const store = await loadStore(101); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA, agentC]); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA], + }); + + assert.deepEqual(savedAudiences(), { + [scope]: [agentB, agentA, agentC], + }); +}); + +test("removal while send awaits wins over late success", async () => { const store = await loadStore(3); - const scope = `${ownerA}:channel-a:channel`; - store.setPersistentAgentAudience(scope, [agentA, agentB]); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope); store.removePersistentAgentAudienceMember(scope, agentA); + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revisionAtSubmit, + explicitAgentPubkeys: [agentA], + }); - assert.deepEqual(currentAudiences(store), { [scope]: [agentB] }); + assert.deepEqual(savedAudiences(), { [scope]: [] }); }); test("removing final chip preserves an explicit empty scope", async () => { const store = await loadStore(4); - const scope = `${ownerA}:channel-a:channel`; + const scope = `${ownerA}:channel-a:thread:root`; store.setPersistentAgentAudience(scope, [agentA]); store.removePersistentAgentAudienceMember(scope, agentA); - assert.deepEqual(currentAudiences(store), { [scope]: [] }); + assert.deepEqual(savedAudiences(), { [scope]: [] }); }); -test("invalid, duplicate, and differently-cased pubkeys normalize", async () => { +test("completion after disabling cannot repopulate audiences", async () => { const store = await loadStore(5); const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope); + store.setPersistentAgentAudienceEnabled(false); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revisionAtSubmit, + explicitAgentPubkeys: [agentB], + }); + + assert.deepEqual(savedAudiences(), {}); +}); + +test("invalid, duplicate, and differently-cased pubkeys normalize", async () => { + const store = await loadStore(6); + const scope = `${ownerA}:channel-a:timeline`; store.setPersistentAgentAudience(scope, [ agentA.toUpperCase(), agentA, "bad", ]); - assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); + assert.deepEqual(savedAudiences(), { [scope]: [agentA] }); }); -test("in-memory audiences retain only the 200 most recently touched scopes", async () => { - const store = await loadStore(6); +test("new recipients retain explicit mention order", async () => { + const store = await loadStore(9); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] }); +}); + +test("persistent audiences retain only the 200 most recently touched scopes", async () => { + const store = await loadStore(11); for ( let index = 0; - index < store.MAX_IN_MEMORY_AGENT_AUDIENCES + 2; + index < store.MAX_PERSISTENT_AGENT_AUDIENCES + 2; index++ ) { store.setPersistentAgentAudience(`scope-${index}`, [agentA]); } - const bounded = currentAudiences(store); - assert.equal( - Object.keys(bounded).length, - store.MAX_IN_MEMORY_AGENT_AUDIENCES, - ); - assert.equal(bounded["scope-0"], undefined); - assert.equal(bounded["scope-1"], undefined); - assert.deepEqual(bounded["scope-201"], [agentA]); + const saved = savedAudiences(); + assert.equal(Object.keys(saved).length, store.MAX_PERSISTENT_AGENT_AUDIENCES); + assert.equal(saved["scope-0"], undefined); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-201"], [agentA]); store.setPersistentAgentAudience("scope-2", [agentB]); store.setPersistentAgentAudience("scope-new", [agentC]); - const retouched = currentAudiences(store); + const retouched = savedAudiences(); assert.equal(retouched["scope-3"], undefined); assert.deepEqual(retouched["scope-2"], [agentB]); assert.deepEqual(retouched["scope-new"], [agentC]); }); -test("reset clears every audience for refresh and community boundaries", async () => { - const store = await loadStore(7); - const scope = `${ownerA}:channel-a:channel`; - store.setPersistentAgentAudience(scope, [agentA]); - - store.resetPersistentAgentAudienceStore(); - - assert.deepEqual(currentAudiences(store), {}); -}); - -test("channel and thread composers share the channel audience scope", async () => { - const store = await loadStore(8); - const channelScope = store.getPersistentAgentAudienceScope({ - ownerPubkey: ownerA, - channelId: "channel-a", +test("an unchanged touch refreshes LRU without revision or emit", async () => { + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM( + "
", + { + url: "http://localhost", + }, + ); + const writes = []; + Object.defineProperty(dom.window, "localStorage", { + configurable: true, + value: createStorage((key, value) => writes.push([key, String(value)])), }); - const threadScope = store.getPersistentAgentAudienceScope({ - ownerPubkey: ownerA, - channelId: "channel-a", - threadRootId: "root", + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, }); + loadSequence += 1; + const store = await import( + `./persistentAgentAudience.ts?test=${Date.now()}-touch-${loadSequence}` + ); + const touchedScope = "scope-0"; + store.setPersistentAgentAudience(touchedScope, [agentA]); + for (let index = 1; index < store.MAX_PERSISTENT_AGENT_AUDIENCES; index++) { + store.setPersistentAgentAudience(`scope-${index}`, [agentA]); + } - assert.equal(channelScope, `${ownerA}:channel-a:channel`); - assert.equal(threadScope, channelScope); -}); - -test("delayed promotion cannot overwrite a newer audience choice", async () => { - const store = await loadStore(9); - const scope = `${ownerA}:channel-a:channel`; - store.setPersistentAgentAudience(scope, []); - const sendRevision = store.getPersistentAgentAudienceRevision(scope); + const React = await import("react"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let renderCount = 0; + function Probe() { + store.usePersistentAgentAudience(touchedScope); + renderCount += 1; + return null; + } + await React.act(async () => root.render(React.createElement(Probe))); + const revision = store.getPersistentAgentAudienceRevision(touchedScope); + const renderCountBeforeTouch = renderCount; + writes.length = 0; - store.addPersistentAgentAudienceMember(scope, agentA); - store.removePersistentAgentAudienceMember(scope, agentA); - const result = store.promotePersistentAgentAudienceIfUnchanged({ - expectedRevision: sendRevision, - pubkeys: [agentA], - scope, + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); }); - assert.equal(result, null); - assert.deepEqual(currentAudiences(store), { [scope]: [] }); -}); + assert.equal(writes.length, 1); + assert.equal(writes[0][0], audienceStorageKey); + assert.deepEqual(JSON.parse(writes[0][1])[touchedScope], [agentA]); + assert.equal(Object.keys(JSON.parse(writes[0][1])).at(-1), touchedScope); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); -test("stale auto-pin Undo cannot remove a newer explicit choice", async () => { - const store = await loadStore(10); - const scope = `${ownerA}:channel-a:channel`; - store.setPersistentAgentAudience(scope, []); - const appliedRevision = store.promotePersistentAgentAudienceIfUnchanged({ - expectedRevision: store.getPersistentAgentAudienceRevision(scope), - pubkeys: [agentA], - scope, + writes.length = 0; + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); }); - assert.notEqual(appliedRevision, null); + assert.equal(writes.length, 0); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); - store.removePersistentAgentAudienceMember(scope, agentA); - store.addPersistentAgentAudienceMember(scope, agentA); - const removed = store.removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: appliedRevision.revision, - pubkeys: [agentA], - scope, + await React.act(async () => { + store.setPersistentAgentAudience("scope-new", [agentB]); }); + const saved = savedAudiences(); + assert.deepEqual(saved[touchedScope], [agentA]); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-new"], [agentB]); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); - assert.equal(removed, false); - assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); + await React.act(async () => root.unmount()); + dom.window.close(); }); -test("promotion reports only newly added agents for transactional Undo", async () => { - const store = await loadStore(11); - const scope = `${ownerA}:channel-a:channel`; - store.setPersistentAgentAudience(scope, [agentA]); - const promotion = store.promotePersistentAgentAudienceIfUnchanged({ - expectedRevision: store.getPersistentAgentAudienceRevision(scope), - pubkeys: [agentA, agentB], - scope, +test("legacy persistence starts disabled with an empty audience", async () => { + const legacyScope = `${ownerA}:channel-a:thread:legacy`; + const store = await loadStore(12, [ + [legacyEnabledStorageKey, "1"], + [legacyAudienceStorageKey, JSON.stringify({ [legacyScope]: [agentA] })], + ]); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + expectedRevision: store.getPersistentAgentAudienceRevision(legacyScope), + explicitAgentPubkeys: [agentA], + scope: legacyScope, }); + assert.equal(window.localStorage.getItem(enabledStorageKey), null); + assert.equal(window.localStorage.getItem(audienceStorageKey), null); + + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(legacyScope, [agentB]); + assert.equal(window.localStorage.getItem(enabledStorageKey), "1"); + assert.deepEqual(savedAudiences(), { [legacyScope]: [agentB] }); +}); - assert.deepEqual(promotion.promotedPubkeys, [agentB]); +test("timeline scope is intentionally unsupported", async () => { + const store = await loadStore(7); assert.equal( - store.removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: promotion.revision, - pubkeys: promotion.promotedPubkeys, - scope, + store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", }), - true, + null, ); - assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + +test("thread root audience initializes once and explicit clear wins on reopen", async () => { + const store = await loadStore(10); + const scope = `${ownerA}:channel-a:thread:root`; + store.setPersistentAgentAudienceEnabled(true); + + store.initializePersistentAgentAudience(scope, [agentB, agentA]); + assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] }); + + store.setPersistentAgentAudience(scope, []); + store.initializePersistentAgentAudience(scope, [agentA]); + assert.deepEqual(savedAudiences(), { [scope]: [] }); }); diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 018a7da489a..39489cb1721 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -1,16 +1,22 @@ import * as React from "react"; -export const MAX_IN_MEMORY_AGENT_AUDIENCES = 200; +const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active:v2"; +const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v3"; +export const MAX_PERSISTENT_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); let revisionClock = 0; let defaultRevision = 0; -let audiences: Record = {}; +let generation = 0; +let enabled = readEnabled(); +let audiences = readAudiences(); let snapshot = buildSnapshot(); export type PersistentAgentAudienceSnapshot = Readonly<{ + enabled: boolean; audiences: Readonly>; + generation: number; }>; type PersistentAgentAudienceScopeInput = { @@ -25,17 +31,49 @@ function normalizePubkeys(pubkeys: Iterable): string[] { ].filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)); } +function readEnabled(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ENABLED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + function boundAudiences( value: Record, ): Record { const entries = Object.entries(value); - return entries.length <= MAX_IN_MEMORY_AGENT_AUDIENCES + return entries.length <= MAX_PERSISTENT_AGENT_AUDIENCES ? value - : Object.fromEntries(entries.slice(-MAX_IN_MEMORY_AGENT_AUDIENCES)); + : Object.fromEntries(entries.slice(-MAX_PERSISTENT_AGENT_AUDIENCES)); +} + +function readAudiences(): Record { + if (typeof window === "undefined") return {}; + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(AUDIENCES_STORAGE_KEY) ?? "{}", + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {}; + + const result: Record = {}; + for (const [scope, value] of Object.entries(parsed)) { + if (scope && Array.isArray(value)) { + result[scope] = normalizePubkeys( + value.filter((entry): entry is string => typeof entry === "string"), + ); + } + } + return boundAudiences(result); + } catch { + return {}; + } } function buildSnapshot(): PersistentAgentAudienceSnapshot { - return { audiences }; + return { enabled, audiences, generation }; } function emit(): void { @@ -43,22 +81,69 @@ function emit(): void { for (const listener of listeners) listener(); } +function persistAudiences(): void { + try { + window.localStorage.setItem( + AUDIENCES_STORAGE_KEY, + JSON.stringify(audiences), + ); + } catch { + // Persistence is best-effort; the live session still uses in-memory state. + } +} + +function advanceRevision(scope: string): void { + revisionClock += 1; + revisions.set(scope, revisionClock); +} + +export function setPersistentAgentAudienceEnabled(nextEnabled: boolean): void { + if (enabled === nextEnabled) return; + enabled = nextEnabled; + if (!nextEnabled) { + generation += 1; + revisionClock += 1; + defaultRevision = revisionClock; + revisions.clear(); + audiences = {}; + persistAudiences(); + } + try { + window.localStorage.setItem(ENABLED_STORAGE_KEY, nextEnabled ? "1" : "0"); + } catch { + // Persistence is best-effort. + } + emit(); +} + export function getPersistentAgentAudienceScope({ ownerPubkey, channelId, + threadRootId = null, }: PersistentAgentAudienceScopeInput): string | null { const owner = ownerPubkey.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null; - // Thread composers intentionally share their parent channel's audience. - return `${owner}:${channelId}:channel`; + if (!threadRootId) return null; + return `${owner}:${channelId}:thread:${threadRootId}`; } -export function resetPersistentAgentAudienceStore(): void { - revisionClock += 1; - defaultRevision = revisionClock; - revisions.clear(); - audiences = {}; - emit(); +export function getPersistentAgentAudienceGeneration(): number { + return generation; +} + +export function getPersistentAgentAudienceRevision(scope: string): number { + return revisions.get(scope) ?? defaultRevision; +} + +export function initializePersistentAgentAudience( + scope: string, + pubkeys: Iterable, +): void { + if (!enabled || !scope) return; + setPersistentAgentAudience( + scope, + Object.hasOwn(audiences, scope) ? audiences[scope] : pubkeys, + ); } export function setPersistentAgentAudience( @@ -77,6 +162,7 @@ export function setPersistentAgentAudience( const nextAudiences = { ...audiences }; delete nextAudiences[scope]; audiences = boundAudiences({ ...nextAudiences, [scope]: current }); + persistAudiences(); return; } @@ -86,64 +172,35 @@ export function setPersistentAgentAudience( for (const revisedScope of revisions.keys()) { if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); } - revisionClock += 1; - revisions.set(scope, revisionClock); + advanceRevision(scope); + persistAudiences(); emit(); } -export function getPersistentAgentAudienceRevision(scope: string): number { - return revisions.get(scope) ?? defaultRevision; -} - -export function promotePersistentAgentAudienceIfUnchanged({ +export function promotePersistentAgentAudience({ + expectedGeneration, expectedRevision, - pubkeys, + explicitAgentPubkeys, scope, }: { - expectedRevision: number; - pubkeys: Iterable; - scope: string; -}): { promotedPubkeys: string[]; revision: number } | null { - if (getPersistentAgentAudienceRevision(scope) !== expectedRevision) - return null; - const promotedPubkeys = normalizePubkeys(pubkeys).filter( - (pubkey) => !(audiences[scope] ?? []).includes(pubkey), - ); - if (promotedPubkeys.length === 0) return null; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + scope: string | null; +}): void { + if ( + !enabled || + expectedGeneration !== generation || + !scope || + (expectedRevision !== null && + getPersistentAgentAudienceRevision(scope) !== expectedRevision) + ) { + return; + } setPersistentAgentAudience(scope, [ + ...explicitAgentPubkeys, ...(audiences[scope] ?? []), - ...promotedPubkeys, ]); - return { - promotedPubkeys, - revision: getPersistentAgentAudienceRevision(scope), - }; -} - -export function removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision, - pubkeys, - scope, -}: { - expectedRevision: number; - pubkeys: Iterable; - scope: string; -}): boolean { - if (getPersistentAgentAudienceRevision(scope) !== expectedRevision) - return false; - const removals = new Set(normalizePubkeys(pubkeys)); - setPersistentAgentAudience( - scope, - (audiences[scope] ?? []).filter((pubkey) => !removals.has(pubkey)), - ); - return true; -} - -export function addPersistentAgentAudienceMember( - scope: string, - pubkey: string, -): void { - setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), pubkey]); } export function removePersistentAgentAudienceMember( @@ -163,23 +220,26 @@ function subscribe(listener: () => void): () => void { return () => listeners.delete(listener); } -export function getPersistentAgentAudienceSnapshot(): PersistentAgentAudienceSnapshot { - return snapshot; -} - function getSnapshot(): PersistentAgentAudienceSnapshot { - return getPersistentAgentAudienceSnapshot(); + return snapshot; } const serverSnapshot: PersistentAgentAudienceSnapshot = { + enabled: false, audiences: {}, + generation: 0, }; export function usePersistentAgentAudience(scope: string | null): { + enabled: boolean; pubkeys: readonly string[]; - addPubkey: (pubkey: string) => void; + generation: number; + revision: number; + setEnabled: (enabled: boolean) => void; + promotePubkeys: typeof promotePersistentAgentAudience; removePubkey: (pubkey: string) => void; clear: () => void; + initialize: (pubkeys: Iterable) => void; } { const state = React.useSyncExternalStore( subscribe, @@ -188,11 +248,14 @@ export function usePersistentAgentAudience(scope: string | null): { ); const resolvedScope = scope ?? ""; return { + enabled: state.enabled, pubkeys: resolvedScope ? (state.audiences[resolvedScope] ?? []) : [], - addPubkey: React.useCallback( - (pubkey) => addPersistentAgentAudienceMember(resolvedScope, pubkey), - [resolvedScope], - ), + generation: state.generation, + revision: resolvedScope + ? getPersistentAgentAudienceRevision(resolvedScope) + : 0, + setEnabled: setPersistentAgentAudienceEnabled, + promotePubkeys: promotePersistentAgentAudience, removePubkey: React.useCallback( (pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey), [resolvedScope], @@ -201,5 +264,9 @@ export function usePersistentAgentAudience(scope: string | null): { () => setPersistentAgentAudience(resolvedScope, []), [resolvedScope], ), + initialize: React.useCallback( + (pubkeys) => initializePersistentAgentAudience(resolvedScope, pubkeys), + [resolvedScope], + ), }; } diff --git a/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs deleted file mode 100644 index f8add9c84a5..00000000000 --- a/desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 5150904ddb7..00000000000 --- a/desktop/src/features/messages/lib/recentMentionPubkeys.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 0ac56e72eb2..00000000000 --- a/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 5748ca2098a..00000000000 --- a/desktop/src/features/messages/lib/useDefaultAgentSuggestion.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/useMentionSelection.ts b/desktop/src/features/messages/lib/useMentionSelection.ts deleted file mode 100644 index fb0dee27a55..00000000000 --- a/desktop/src/features/messages/lib/useMentionSelection.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from "react"; - -import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; - -export type MentionPickerMode = "first-agent" | "preserve" | null; - -export function useMentionSelection(suggestions: MentionSuggestion[]) { - const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0); - const preferAgentSelectionRef = React.useRef(false); - - React.useEffect(() => { - setMentionSelectedIndex((current) => { - if (suggestions.length === 0) return 0; - if (preferAgentSelectionRef.current) { - preferAgentSelectionRef.current = false; - const firstAgentIndex = suggestions.findIndex( - (suggestion) => suggestion.isAgent && suggestion.pubkey, - ); - if (firstAgentIndex >= 0) return firstAgentIndex; - } - return Math.min(current, suggestions.length - 1); - }); - }, [suggestions]); - - const clearAgentSelectionPreference = React.useCallback(() => { - preferAgentSelectionRef.current = false; - }, []); - const prepareSelectionPreference = React.useCallback( - (preference: MentionPickerMode) => { - preferAgentSelectionRef.current = preference === "first-agent"; - }, - [], - ); - return { - clearAgentSelectionPreference, - mentionSelectedIndex, - prepareSelectionPreference, - setMentionSelectedIndex, - }; -} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1a6ca1be4a7..f9b73548de3 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -31,26 +31,21 @@ import { } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { AutocompleteEdit } from "./useRichTextEditor"; -import type { ChannelMember, ChannelType } from "@/shared/api/types"; +import type { + AgentPersona, + ChannelMember, + ChannelType, +} from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; -import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; -import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; +import { hasMention } from "./hasMention"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; -import { - extractMentionPersonasFromMaps, - type PersonaMentionTarget, -} from "./extractMentionPersonas"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; -import { - type MentionPickerMode, - useMentionSelection, -} from "./useMentionSelection"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { @@ -63,11 +58,14 @@ import { type MentionCandidate, mentionCandidateLabel, } from "./mentionCandidates"; -const MENTION_DEBOUNCE_MS = 120, - MENTION_SUGGESTION_LIMIT = 50; +const MENTION_DEBOUNCE_MS = 120; +const MENTION_SUGGESTION_LIMIT = 50; +export type PersonaMentionTarget = { + displayName: string; + persona: AgentPersona; +}; type UseMentionsOptions = { channelType?: ChannelType | null; - recentMentionPubkeys?: readonly string[]; }; export function useMentions( channelId: string | null, @@ -77,9 +75,7 @@ export function useMentions( ) { const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionStartIndex, setMentionStartIndex] = React.useState(0); - const mentionPickerOriginRef = React.useRef<"inline" | "explicit" | null>( - null, - ); + const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0); const [selectedMentionNames, setSelectedMentionNames] = React.useState< string[] >([]); @@ -175,10 +171,6 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const activeAgentPubkeys = useActiveAgentPubkeys( - managedAgentsQuery.data, - relayAgentsQuery.data, - ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -285,7 +277,6 @@ 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, @@ -331,7 +322,6 @@ 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, @@ -353,7 +343,6 @@ export function useMentions( (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: agent.ownerPubkey, isAgent: true, - isActiveAgent: agent.status !== "offline", }); } for (const agent of managedAgentsQuery.data ?? []) { @@ -363,8 +352,6 @@ export function useMentions( displayName: agent.name, isMember: false, isAgent: true, - isActiveAgent: - agent.status === "running" || agent.status === "deployed", isManagedAgent: true, personaId: agent.personaId ?? undefined, personaName: @@ -420,7 +407,6 @@ export function useMentions( ); }, [ activePersonaById, - activeAgentPubkeys, activePersonas, userSearchResults, canSearchGlobalUsers, @@ -472,9 +458,11 @@ export function useMentions( () => uniqueAutocompleteLabels(mentionCandidatesWithTeams), [mentionCandidatesWithTeams], ); + const highlightNames = React.useMemo(() => { const names: string[] = []; const seen = new Set(); + for (const name of selectedMentionNames) { const trimmed = name.trim(); if (trimmed && !seen.has(trimmed.toLowerCase())) { @@ -482,11 +470,14 @@ export function useMentions( seen.add(trimmed.toLowerCase()); } } + return names; }, [selectedMentionNames]); + const agentHighlightNames = React.useMemo(() => { const names: string[] = []; const seen = new Set(); + for (const name of selectedAgentMentionNames) { const trimmed = name.trim(); if (trimmed && !seen.has(trimmed.toLowerCase())) { @@ -494,12 +485,15 @@ export function useMentions( seen.add(trimmed.toLowerCase()); } } + return names; }, [selectedAgentMentionNames]); + const searchableNamesLower = React.useMemo( () => searchableNames.map((n) => n.toLowerCase()), [searchableNames], ); + const debounceTimerRef = React.useRef | null>( null, ); @@ -507,9 +501,11 @@ export function useMentions( const latestCursorRef = React.useRef(0); const flushedMentionStartIndexRef = React.useRef(null); const searchableNamesLowerRef = React.useRef(searchableNamesLower); + React.useEffect(() => { searchableNamesLowerRef.current = searchableNamesLower; }, [searchableNamesLower]); + React.useEffect( () => () => { if (debounceTimerRef.current !== null) { @@ -518,10 +514,12 @@ export function useMentions( }, [], ); + const matchingSuggestions = React.useMemo(() => { if (mentionQuery === null) { return []; } + return rankMentionCandidates( mentionCandidatesWithTeams, mentionQuery, @@ -549,34 +547,29 @@ 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(); } }, [userSearchQuery]); + const suggestions = React.useMemo(() => { if (mentionQuery === null) { return []; } + if (matchingSuggestions.length > 0) { return matchingSuggestions; } + if (userSearchQuery.isFetching) { return filterCachedAgentSuggestions( previousSuggestionsRef.current, mentionCandidatesWithTeams, ); } + return []; }, [ matchingSuggestions, @@ -584,33 +577,42 @@ export function useMentions( mentionQuery, userSearchQuery.isFetching, ]); + React.useEffect(() => { if (mentionQuery === null) { previousSuggestionsRef.current = []; return; } + if (matchingSuggestions.length > 0) { previousSuggestionsRef.current = matchingSuggestions; } else if (!userSearchQuery.isFetching) { previousSuggestionsRef.current = []; } }, [matchingSuggestions, mentionQuery, userSearchQuery.isFetching]); - const mentionSelection = useMentionSelection(suggestions); - const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } = - mentionSelection; + + React.useEffect(() => { + setMentionSelectedIndex((current) => + suggestions.length === 0 ? 0 : Math.min(current, suggestions.length - 1), + ); + }, [suggestions.length]); + const isMentionOpen = mentionQuery !== null && suggestions.length > 0; + const insertMention = React.useCallback( (suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => { if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } + const displayName = suggestion.displayName; const teamMembers = suggestion.kind === "team" ? suggestion.teamMembers : null; const insertText = teamMembers ? formatTeamMention(displayName, teamMembers) : `@${displayName} `; + const mentions = mentionMapRef.current; const personaMentions = personaMentionMapRef.current; const selectedMentions = teamMembers ?? [suggestion]; @@ -659,9 +661,9 @@ export function useMentions( } trimMapToSize(mentions, 200); trimMapToSize(personaMentions, 200); - mentionPickerOriginRef.current = null; setMentionQuery(null); - setSelected(0); + setMentionSelectedIndex(0); + const startIndex = flushedMentionStartIndexRef.current ?? mentionStartIndex; flushedMentionStartIndexRef.current = null; @@ -671,20 +673,24 @@ export function useMentions( insertText, }; }, - [knownAgentPubkeys, mentionStartIndex, setSelected], + [knownAgentPubkeys, mentionStartIndex], ); + const registerMentionPubkey = React.useCallback( (displayName: string, pubkey: string, options?: { isAgent?: boolean }) => { const trimmedName = displayName.trim(); if (!trimmedName) { return; } + mentionMapRef.current.set(trimmedName, pubkey); personaMentionMapRef.current.delete(trimmedName); trimMapToSize(mentionMapRef.current, 200); + setSelectedMentionNames((current) => appendUniqueName(current, trimmedName), ); + if (options?.isAgent) { setSelectedAgentMentionNames((current) => { const next = appendUniqueName(current, trimmedName); @@ -695,6 +701,7 @@ export function useMentions( }, [], ); + const insertResolvedMention = React.useCallback( ({ displayName, @@ -718,14 +725,17 @@ export function useMentions( }, [registerMentionPubkey], ); + const getMentionDisplayName = React.useCallback( (pubkey: string): string | null => { const normalizedPubkey = normalizePubkey(pubkey); + for (const [displayName, mentionPubkey] of mentionMapRef.current) { if (normalizePubkey(mentionPubkey) === normalizedPubkey) { return displayName; } } + const candidate = mentionCandidates.find( (item) => item.pubkey !== undefined && @@ -735,6 +745,7 @@ export function useMentions( }, [mentionCandidates], ); + const isAgentPubkey = React.useCallback( (pubkey: string): boolean => knownAgentPubkeys.has(normalizePubkey(pubkey)), [knownAgentPubkeys], @@ -744,34 +755,21 @@ export function useMentions( managedAgentPubkeys.has(normalizePubkey(pubkey)), [managedAgentPubkeys], ); - const isInlineMentionSelection = React.useCallback( - () => mentionPickerOriginRef.current === "inline", - [], - ); const autocompleteGenerationRef = React.useRef(0); const updateMentionQuery = React.useCallback( (value: string, cursorPosition: number) => { - mentionSelection.clearAgentSelectionPreference(); const generation = ++autocompleteGenerationRef.current; latestValueRef.current = value; latestCursorRef.current = cursorPosition; - const activeInlineMention = detectPrefixQuery( - "@", - value, - cursorPosition, - searchableNamesLowerRef.current, - ); - if (activeInlineMention) { - mentionPickerOriginRef.current = "inline"; - } else if (mentionPickerOriginRef.current === "inline") { - mentionPickerOriginRef.current = null; - } + if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); } + debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null; if (generation !== autocompleteGenerationRef.current) return; + const mention = detectPrefixQuery( "@", latestValueRef.current, @@ -779,37 +777,17 @@ export function useMentions( searchableNamesLowerRef.current, ); if (mention) { - mentionPickerOriginRef.current = "inline"; setMentionQuery(mention.query); setMentionStartIndex(mention.startIndex); - setSelected(0); + setMentionSelectedIndex(0); } else { setMentionQuery(null); } }, MENTION_DEBOUNCE_MS); }, - [mentionSelection.clearAgentSelectionPreference, setSelected], - ); - const openMentionPicker = React.useCallback( - (cursorPosition: number, preference: MentionPickerMode = null) => { - autocompleteGenerationRef.current += 1; - if (debounceTimerRef.current !== null) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - flushedMentionStartIndexRef.current = null; - mentionPickerOriginRef.current = "explicit"; - if (preference === "preserve") { - setMentionStartIndex(cursorPosition); - return; - } - mentionSelection.prepareSelectionPreference(preference); - setMentionQuery(""); - setMentionStartIndex(cursorPosition); - setSelected(0); - }, - [mentionSelection.prepareSelectionPreference, setSelected], + [], ); + const extractMentionPubkeysForCurrentMentions = React.useCallback( (text: string): string[] => { const extracted = extractMentionPubkeys({ @@ -842,15 +820,31 @@ export function useMentions( sharedChannelIds, refetchManagedAgents: managedAgentsQuery.refetch, }); + const extractMentionPersonas = React.useCallback( - (text: string): PersonaMentionTarget[] => - extractMentionPersonasFromMaps( - text, - personaMentionMapRef.current, - activePersonaById, - ), + (text: string): PersonaMentionTarget[] => { + const targets: PersonaMentionTarget[] = []; + const seen = new Set(); + + for (const [displayName, personaId] of personaMentionMapRef.current) { + if (seen.has(personaId) || !hasMention(text, displayName)) { + continue; + } + + const persona = activePersonaById.get(personaId); + if (!persona) { + continue; + } + + targets.push({ displayName, persona }); + seen.add(personaId); + } + + return targets; + }, [activePersonaById], ); + const cancelMentionAutocomplete = React.useCallback(() => { autocompleteGenerationRef.current += 1; if (debounceTimerRef.current !== null) { @@ -858,11 +852,9 @@ export function useMentions( debounceTimerRef.current = null; } flushedMentionStartIndexRef.current = null; - mentionPickerOriginRef.current = null; - mentionSelection.clearAgentSelectionPreference(); setMentionQuery(null); - setSelected(0); - }, [mentionSelection.clearAgentSelectionPreference, setSelected]); + setMentionSelectedIndex(0); + }, []); const clearMentions = React.useCallback(() => { cancelMentionAutocomplete(); mentionMapRef.current.clear(); @@ -872,6 +864,7 @@ export function useMentions( setSelectedMentionNames([]); setSelectedAgentMentionNames([]); }, [cancelMentionAutocomplete]); + const { getDraftMentionRefs, restoreDraftMentionRefs } = useDraftMentionRouting({ mentionMapRef, @@ -881,6 +874,7 @@ export function useMentions( setSelectedNames: setSelectedMentionNames, setSelectedAgentNames: setSelectedAgentMentionNames, }); + const handleMentionKeyDown = React.useCallback( ( event: React.KeyboardEvent, @@ -888,20 +882,23 @@ export function useMentions( if (!isMentionOpen) { return { handled: false }; } + if (event.key === "ArrowDown") { event.preventDefault(); - setSelected((current) => + setMentionSelectedIndex((current) => current < suggestions.length - 1 ? current + 1 : 0, ); return { handled: true }; } + if (event.key === "ArrowUp") { event.preventDefault(); - setSelected((current) => + setMentionSelectedIndex((current) => current > 0 ? current - 1 : suggestions.length - 1, ); return { handled: true }; } + if ( event.key === "Tab" || (event.key === "Enter" && @@ -911,6 +908,7 @@ export function useMentions( !event.shiftKey) ) { event.preventDefault(); + if (debounceTimerRef.current !== null) { const flushed = flushMentionDebounce({ debounceTimerRef, @@ -927,7 +925,6 @@ export function useMentions( }); if (flushed?.type === "match") { flushedMentionStartIndexRef.current = flushed.startIndex; - mentionPickerOriginRef.current = "inline"; setMentionQuery(null); // reset so dropdown closes return { handled: true, suggestion: flushed.suggestion }; } @@ -936,13 +933,16 @@ export function useMentions( return { handled: true }; } } + return { handled: true, suggestion: suggestions[mentionSelectedIndex] }; } + if (event.key === "Escape") { event.preventDefault(); cancelMentionAutocomplete(); // full cancel incl. pending debounce return { handled: true }; } + return { handled: false }; }, [ @@ -956,14 +956,13 @@ export function useMentions( options?.channelType, ownerProfilesQuery.data?.profiles, profiles, - setSelected, suggestions, ], ); + return { cancelMentionAutocomplete, clearMentions, - getDefaultAgentSuggestion, extractMentionPersonas, extractMentionPubkeys: extractMentionPubkeysForCurrentMentions, revalidateMentionPubkeys, @@ -976,13 +975,10 @@ export function useMentions( agentKnownNames: agentHighlightNames, isAgentPubkey, isManagedAgentPubkey, - isInlineMentionSelection, isMentionOpen, knownNames: highlightNames, memberPubkeys, mentionSelectedIndex, - mentionStartIndex, - openMentionPicker, registerMentionPubkey, restoreDraftMentionRefs, suggestions, @@ -992,4 +988,5 @@ export function useMentions( updateMentionQuery, }; } + export type UseMentionsResult = ReturnType; diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index fa9644fa61b..46b605fc76c 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -72,8 +72,6 @@ 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) @@ -776,7 +774,6 @@ export function useRichTextEditor({ toOffset: number, text: string, customEmojiShortcode?: string, - preserveSelection = false, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -809,23 +806,19 @@ export function useRichTextEditor({ } const tr = editor.state.tr.insertText(text, fromPM, toPM); - 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); + // 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); editor.view.dispatch(tr); editor.view.focus(); - if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); + 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 deleted file mode 100644 index 4fe40da4b37..00000000000 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; - -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - Element: dom.window.Element, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - Node: dom.window.Node, - window: dom.window, - }); -}); - -afterEach(async () => { - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -const agent = { - avatarUrl: null, - displayName: "Agent Ada", - pubkey: "agent-pubkey", -}; -const secondAgent = { - avatarUrl: null, - displayName: "Agent Bea", - pubkey: "second-agent-pubkey", -}; -const thirdAgent = { - avatarUrl: null, - displayName: "Agent Cia", - pubkey: "third-agent-pubkey", -}; - -test("mention control expands with automatically mentioned agents", async () => { - const React = await import("react"); - const { fireEvent, render } = await import("@testing-library/react"); - const { TooltipProvider } = await import("@/shared/ui/tooltip"); - const { ComposerMentionButton } = await import( - "./ComposerAddressControls.tsx" - ); - let opened = 0; - const removed = []; - const renderButton = (agents) => - React.createElement( - TooltipProvider, - null, - React.createElement(ComposerMentionButton, { - agents, - disabled: false, - onCaptureSelection: () => {}, - onOpen: () => { - opened += 1; - }, - onRemove: (pubkey) => removed.push(pubkey), - showAgents: true, - }), - ); - const view = render(renderButton([agent])); - view.rerender(renderButton([agent, secondAgent, thirdAgent])); - - assert.ok(view.getByTestId("composer-address-locks")); - const avatar = view.getByTestId("composer-address-lock-agent-pubkey"); - assert.ok(avatar); - const manage = view.getByRole("button", { - name: "Manage automatic agent mentions", - }); - assert.match(manage.className, /(?:^|\s)-ml-2(?:\s|$)/); - assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/); - assert.match(manage.parentElement?.className ?? "", /(?:^|\s)pl-2(?:\s|$)/); - assert.match( - manage.parentElement?.className ?? "", - /(?:^|\s)pr-1\.5(?:\s|$)/, - ); - assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", - /(?:^|\s)bg-primary\/15(?:\s|$)/, - ); - assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", - /(?:^|\s)text-primary(?:\s|$)/, - ); - assert.doesNotMatch( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", - /(?:^|\s)bg-accent\/70(?:\s|$)/, - ); - assert.doesNotMatch( - avatar.querySelector("span")?.className ?? "", - /(?:^|\s)ring(?:-|\s)/, - ); - for (const addedAgent of [secondAgent, thirdAgent]) { - const addedAvatar = view.getByTestId( - `composer-address-lock-${addedAgent.pubkey}`, - ); - assert.equal(addedAvatar.parentElement?.style.opacity, "0"); - assert.match( - addedAvatar.parentElement?.style.transform ?? "", - /scale\(0.8\)/, - ); - } - 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"]); - 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 deleted file mode 100644 index f141a4d2fdc..00000000000 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { ArrowUp, AtSign, X } from "lucide-react"; -import { - AnimatePresence, - motion, - useAnimationControls, - useReducedMotion, -} from "motion/react"; -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 = { - avatarUrl: string | null; - displayName: string; - pubkey: string; -}; - -type AddressAnimationProps = { - pulseVersion: number; - shakeVersion: number; -}; - -function AddressedAgentAvatar({ - agent, - pulseVersion, - shakeVersion, -}: AddressAnimationProps & { agent: ComposerAddressAgent }) { - const controls = useAnimationControls(); - const shouldReduceMotion = useReducedMotion(); - const previousPulseVersionRef = React.useRef(0); - const previousShakeVersionRef = React.useRef(0); - - React.useEffect(() => { - if (pulseVersion <= previousPulseVersionRef.current) return; - previousPulseVersionRef.current = pulseVersion; - if (shouldReduceMotion) return; - void controls.start({ - scale: [1, 1.3, 0.96, 1.08, 1], - y: [0, -4, 1, -1, 0], - transition: { duration: 0.48, ease: "easeOut" }, - }); - }, [controls, pulseVersion, shouldReduceMotion]); - - React.useEffect(() => { - if (shakeVersion <= previousShakeVersionRef.current) return; - previousShakeVersionRef.current = shakeVersion; - if (shouldReduceMotion) return; - controls.stop(); - controls.set({ scale: 1, x: 0, y: 0 }); - void controls.start({ - x: [0, -4, 4, -3, 3, -1.5, 1.5, 0], - transition: { duration: 0.42, ease: "easeOut" }, - }); - }, [controls, shakeVersion, shouldReduceMotion]); - - return ( - - - - ); -} - -function RemainingAgentCount({ count }: { count: number }) { - return count > 0 ? ( - - +{count} - - ) : null; -} - -const VISIBLE_AGENT_LIMIT = 3; - -function useNewlyAddedAgentPubkeys( - agents: readonly ComposerAddressAgent[], -): ReadonlySet { - const previousPubkeysRef = React.useRef | null>(null); - const currentPubkeys = new Set(agents.map((agent) => agent.pubkey)); - const newlyAddedPubkeys = new Set(); - - if (previousPubkeysRef.current) { - for (const pubkey of currentPubkeys) { - if (!previousPubkeysRef.current.has(pubkey)) { - newlyAddedPubkeys.add(pubkey); - } - } - } - - React.useEffect(() => { - previousPubkeysRef.current = new Set(agents.map((agent) => agent.pubkey)); - }, [agents]); - - return newlyAddedPubkeys; -} - -const addressEntryTransition = { - type: "spring", - stiffness: 500, - damping: 30, -} as const; - -type AddressAgentsProps = { - agents: readonly ComposerAddressAgent[]; - pulseVersionByPubkey?: Readonly>; - shakeVersionByPubkey?: Readonly>; -}; - -export function ComposerMentionButton({ - agents, - confirmationTitle, - disabled, - onConfirmationDismiss, - onConfirmationTurnOff, - onCaptureSelection, - onOpen, - onRemove, - pulseVersionByPubkey = {}, - shakeVersionByPubkey = {}, - showAgents, -}: AddressAgentsProps & { - confirmationTitle?: string | null; - disabled: boolean; - onConfirmationDismiss?: () => void; - onConfirmationTurnOff?: () => void; - onCaptureSelection: () => void; - onOpen: () => void; - onRemove: (pubkey: string) => void; - showAgents: boolean; -}) { - 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} - - - ) : null} -
- ); -} - -export function ComposerSendButton({ - isSending, - sendDisabled, -}: { - isSending: boolean; - sendDisabled: boolean; -}) { - return ( - - ); -} - -function SendSpinner() { - return ( - - ); -} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index 012c962e0f0..682b7dbfbac 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -1,320 +1,8 @@ import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; +import test from "node:test"; -import { JSDOM } from "jsdom"; import { showMentionAgentProvenanceMarker } from "./MentionAutocomplete.tsx"; -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - dom.window.HTMLElement.prototype.scrollIntoView = () => {}; - Object.assign(globalThis, { - CustomEvent: dom.window.CustomEvent, - document: dom.window.document, - Element: dom.window.Element, - Event: dom.window.Event, - getComputedStyle: dom.window.getComputedStyle.bind(dom.window), - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - Node: dom.window.Node, - ResizeObserver: class { - disconnect() {} - observe() {} - unobserve() {} - }, - window: dom.window, - }); -}); - -afterEach(async () => { - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -test("agent rows offer automatic mention controls", async () => { - const React = await import("react"); - const { fireEvent, render } = await import("@testing-library/react"); - const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); - const { TooltipProvider } = await import("@/shared/ui/tooltip"); - const suggestion = { - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }; - const selected = []; - const toggled = []; - const props = { - suggestions: [suggestion], - selectedIndex: 0, - onSelect: (value) => selected.push(value), - onToggleAlwaysAddressAgent: (value) => toggled.push(value), - lockedAgentPubkeys: new Set(), - }; - const renderAutocomplete = (autocompleteProps) => - React.createElement( - TooltipProvider, - null, - React.createElement(MentionAutocomplete, autocompleteProps), - ); - const view = render(renderAutocomplete(props)); - - assert.equal( - view.queryByText("Hover an agent avatar to keep it addressed"), - null, - ); - const rowAction = view.getByRole("button", { - name: "Mention Agent Ada", - }); - fireEvent.mouseDown(rowAction); - assert.deepEqual(selected, [suggestion]); - - const action = view.getByRole("button", { - name: "Automatically mention Agent Ada", - }); - assert.equal(action.getAttribute("aria-pressed"), "false"); - assert.equal(action.getAttribute("data-state"), "off"); - fireEvent.click(action); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion]); - - view.rerender( - renderAutocomplete({ - ...props, - lockedAgentPubkeys: new Set(["agent-pubkey"]), - }), - ); - const selectedAction = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", - }); - assert.equal(selectedAction.getAttribute("aria-pressed"), "true"); - assert.equal(selectedAction.getAttribute("data-state"), "on"); - fireEvent.click(selectedAction); - assert.deepEqual(toggled, [suggestion, suggestion]); -}); - -test("options expand in place without replacing the people list", async () => { - const React = await import("react"); - const { fireEvent, render } = await import("@testing-library/react"); - const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); - const changes = []; - const suggestion = { - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }; - const view = render( - React.createElement(MentionAutocomplete, { - suggestions: [suggestion], - selectedIndex: 0, - onSelect: () => {}, - keepMentionedAgentsPinned: true, - onKeepMentionedAgentsPinnedChange: (value) => changes.push(value), - }), - ); - - const options = view.getByRole("button", { name: "Options" }); - assert.equal(options.getAttribute("aria-expanded"), "false"); - assert.match(options.parentElement?.className ?? "", /(?:^|\s)w-24(?:\s|$)/); - assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); - assert.equal( - view.queryByRole("switch", { name: "Automatically mention agents" }), - null, - ); - - fireEvent.click(options); - assert.equal(options.getAttribute("aria-expanded"), "true"); - const toggle = view.getByRole("switch", { - name: "Automatically mention agents", - }); - assert.equal(toggle.getAttribute("data-state"), "checked"); - assert.ok(view.getByText("After you mention them once")); - assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); - - fireEvent.click(toggle); - assert.deepEqual(changes, [false]); - - view.rerender( - React.createElement(MentionAutocomplete, { - suggestions: [], - selectedIndex: 0, - onSelect: () => {}, - keepMentionedAgentsPinned: false, - onKeepMentionedAgentsPinnedChange: (value) => changes.push(value), - }), - ); - assert.equal(view.queryByRole("button", { name: "Options" }), null); - - view.rerender( - React.createElement(MentionAutocomplete, { - suggestions: [suggestion], - selectedIndex: 0, - onSelect: () => {}, - keepMentionedAgentsPinned: false, - onKeepMentionedAgentsPinnedChange: (value) => changes.push(value), - }), - ); - assert.equal( - view.getByRole("button", { name: "Options" }).getAttribute("aria-expanded"), - "false", - ); - assert.equal( - view.queryByRole("switch", { name: "Automatically mention agents" }), - null, - ); - assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); -}); - -test("clicking outside dismisses the tray without intercepting its trigger", async () => { - const React = await import("react"); - const { fireEvent, render } = await import("@testing-library/react"); - const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); - const suggestion = { - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }; - let dismissCount = 0; - const view = render( - React.createElement( - React.Fragment, - null, - React.createElement( - "form", - null, - React.createElement( - "button", - { "data-mention-picker-trigger": "", type: "button" }, - "@", - ), - React.createElement(MentionAutocomplete, { - suggestions: [suggestion], - selectedIndex: 0, - onDismiss: () => { - dismissCount += 1; - }, - onSelect: () => {}, - }), - ), - React.createElement("button", { type: "button" }, "Outside"), - React.createElement( - "form", - null, - React.createElement( - "button", - { "data-mention-picker-trigger": "", type: "button" }, - "Other @", - ), - ), - ), - ); - - fireEvent.pointerDown( - view.getByRole("button", { name: "Mention Agent Ada" }), - ); - assert.equal(dismissCount, 0); - - fireEvent.pointerDown(view.getByRole("button", { name: "@" })); - assert.equal(dismissCount, 0); - - fireEvent.pointerDown(view.getByTestId("mention-autocomplete-layer")); - assert.equal(dismissCount, 1); - - fireEvent.pointerDown(view.getByRole("button", { name: "Outside" })); - assert.equal(dismissCount, 2); - - fireEvent.pointerDown(view.getByRole("button", { name: "Other @" })); - assert.equal(dismissCount, 3); -}); - -test("collision npubs sit inline with agent metadata", async () => { - const React = await import("react"); - const { render } = await import("@testing-library/react"); - const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); - const suggestions = [ - { - pubkey: "a".repeat(64), - displayName: "Same Name", - isAgent: true, - ownerLabel: "you", - }, - { - pubkey: "b".repeat(64), - displayName: "Same Name", - isAgent: true, - ownerLabel: "you", - }, - ]; - const view = render( - React.createElement(MentionAutocomplete, { - suggestions, - selectedIndex: 0, - onSelect: () => {}, - }), - ); - - const agentIcons = view.getAllByTestId("mention-agent-icon"); - const collisionNpubs = view.getAllByTestId("mention-collision-npub"); - assert.equal(collisionNpubs.length, 2); - for (const [index, npub] of collisionNpubs.entries()) { - const agentMetadata = agentIcons[index].closest("span")?.parentElement; - assert.equal(npub.parentElement, agentMetadata); - assert.match(agentMetadata?.textContent ?? "", /agentmanaged by younpub1/); - assert.match(npub.className, /(?:^|\s)-translate-y-0\.5(?:\s|$)/); - assert.match(npub.className, /(?:^|\s)leading-none(?:\s|$)/); - assert.match(agentMetadata?.className ?? "", /(?:^|\s)min-h-3\.5(?:\s|$)/); - } -}); - -test("does not intercept Tab from the editor", async () => { - const React = await import("react"); - const { fireEvent, render } = await import("@testing-library/react"); - const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); - const { TooltipProvider } = await import("@/shared/ui/tooltip"); - const suggestions = [ - { - pubkey: "agent-a", - displayName: "Agent Ada", - isAgent: true, - }, - { - pubkey: "agent-b", - displayName: "Agent Bea", - isAgent: true, - }, - ]; - const view = render( - React.createElement( - TooltipProvider, - null, - React.createElement( - "form", - null, - React.createElement( - "div", - { "data-testid": "message-input-scroll" }, - React.createElement("input", { "aria-label": "Message" }), - ), - React.createElement(MentionAutocomplete, { - suggestions, - selectedIndex: 1, - onSelect: () => {}, - onToggleAlwaysAddressAgent: () => {}, - }), - ), - ), - ); - - const input = view.getByRole("textbox", { name: "Message" }); - input.focus(); - const wasNotCancelled = fireEvent.keyDown(input, { key: "Tab" }); - - assert.equal(wasNotCancelled, true); - assert.equal(document.activeElement, input); -}); function suggestion(agentProvenance) { return { pubkey: "1".repeat(64), diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 9287c4f0e13..ac84df439c4 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,8 +1,7 @@ import * as React from "react"; -import { AtSign, Bot, ChevronRight, Users } from "lucide-react"; -import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; -import { motion } from "motion/react"; +import { Bot, Users } from "lucide-react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; +import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; @@ -12,12 +11,8 @@ import { POPOVER_SURFACE_CLASS, } from "@/shared/ui/popoverSurface"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { Switch } from "@/shared/ui/switch"; -import { Toggle } from "@/shared/ui/toggle"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { safeNpub } from "@/shared/lib/nostrUtils"; import { truncatePubkey } from "@/shared/lib/pubkey"; -import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { pubkey?: string; @@ -39,12 +34,6 @@ type MentionAutocompleteProps = { selectedIndex: number; onFetchMore?: () => void; onSelect: (suggestion: MentionSuggestion) => void; - lockedAgentPubkeys?: ReadonlySet; - onToggleAlwaysAddressAgent?: (suggestion: MentionSuggestion) => void; - keepMentionedAgentsPinned?: boolean; - onKeepMentionedAgentsPinnedChange?: (value: boolean) => void; - openOptionsRequest?: number; - onDismiss?: () => void; position?: "above" | "below"; }; @@ -60,72 +49,17 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ selectedIndex, onFetchMore, onSelect, - lockedAgentPubkeys, - onToggleAlwaysAddressAgent, - keepMentionedAgentsPinned = true, - onKeepMentionedAgentsPinnedChange, - openOptionsRequest = 0, - onDismiss, position = "above", }: MentionAutocompleteProps) { - const rootRef = React.useRef(null); - const optionsSurfaceRef = React.useRef(null); const listRef = React.useRef(null); - const optionsId = React.useId(); - const keepPinnedSwitchId = React.useId(); - const [optionsOpen, setOptionsOpen] = React.useState(false); - const alwaysAddressShortcut = getPlatformKeysById("always-address-agent"); React.useEffect(() => { - const activeItem = listRef.current?.querySelector( - `[data-mention-suggestion-index="${selectedIndex}"]`, - ); + const activeItem = listRef.current?.children[selectedIndex] as + | HTMLElement + | undefined; activeItem?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); - React.useEffect(() => { - if (suggestions.length === 0) { - setOptionsOpen(false); - } - }, [suggestions.length]); - - React.useEffect(() => { - if (openOptionsRequest > 0) { - setOptionsOpen(true); - } - }, [openOptionsRequest]); - - React.useEffect(() => { - if (!onDismiss) return; - - const handlePointerDown = (event: PointerEvent) => { - const root = rootRef.current; - const target = event.target; - if (!root || !(target instanceof Node)) return; - if ( - listRef.current?.contains(target) || - optionsSurfaceRef.current?.contains(target) - ) { - return; - } - - const composer = root.closest("form"); - const mentionTrigger = - target instanceof Element - ? target.closest("[data-mention-picker-trigger]") - : null; - if (composer && mentionTrigger && composer.contains(mentionTrigger)) { - return; - } - - onDismiss(); - }; - - document.addEventListener("pointerdown", handlePointerDown, true); - return () => - document.removeEventListener("pointerdown", handlePointerDown, true); - }, [onDismiss]); - const handleScroll = React.useCallback(() => { const list = listRef.current; if (!list || !onFetchMore) return; @@ -154,288 +88,154 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ "absolute left-0 right-0 z-50 px-3 sm:px-4", position === "below" ? "top-full mt-1" : "bottom-full mb-1", )} - data-testid="mention-autocomplete-layer" - ref={rootRef} > -
- {onKeepMentionedAgentsPinnedChange ? ( -
-
+ {suggestions.map((suggestion, index) => { + const suggestionKey = + suggestion.pubkey ?? + (suggestion.personaId ? `persona-${suggestion.personaId}` : null) ?? + (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? + suggestion.displayName; + const hasNameCollision = + (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; + const showAgentProvenanceMarker = showMentionAgentProvenanceMarker( + suggestion, + hasNameCollision, + ); + const ownerLabel = + hasNameCollision && suggestion.agentProvenance + ? null + : suggestion.ownerLabel; + const collisionNpub = + hasNameCollision && suggestion.pubkey + ? safeNpub(suggestion.pubkey) + : null; + + return ( + -
-
- ) : null} -
- {suggestions.map((suggestion, index) => { - const suggestionKey = - suggestion.pubkey ?? - (suggestion.personaId - ? `persona-${suggestion.personaId}` - : null) ?? - (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? - suggestion.displayName; - const hasNameCollision = - (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; - const showAgentProvenanceMarker = showMentionAgentProvenanceMarker( - suggestion, - hasNameCollision, - ); - const ownerLabel = - hasNameCollision && suggestion.agentProvenance - ? null - : suggestion.ownerLabel; - const collisionNpub = - hasNameCollision && suggestion.pubkey - ? safeNpub(suggestion.pubkey) - : null; - const hasMetadataBeforeNpub = Boolean( - suggestion.kind === "team" || + {suggestion.displayName} + + {suggestion.kind === "team" || suggestion.isAgent || suggestion.role || ownerLabel || - suggestion.notInChannel, - ); - const canAlwaysAddress = Boolean( - onToggleAlwaysAddressAgent && - suggestion.isAgent && - suggestion.pubkey, - ); - const isAlwaysAddressed = Boolean( - suggestion.pubkey && - lockedAgentPubkeys?.has(suggestion.pubkey.toLowerCase()), - ); - - return ( -
- - {canAlwaysAddress ? ( - - - - - onToggleAlwaysAddressAgent?.(suggestion) - } - onClick={(event) => event.stopPropagation()} - onMouseDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - pressed={isAlwaysAddressed} - size="xs" - type="button" - > - - - - - - {isAlwaysAddressed - ? "Stop automatically mentioning" - : "Automatically mention"} - - {alwaysAddressShortcut ? ( - - {(alwaysAddressShortcut.includes("+") - ? alwaysAddressShortcut.split("+") - : Array.from(alwaysAddressShortcut) - ).map((key) => ( - {key} - ))} - - ) : null} - - ) : null} -
- ); - })} -
+ {collisionNpub ? ( + + {truncatePubkey(collisionNpub)} + + ) : null} + + + ); + })}
); diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx deleted file mode 100644 index c43d6fa981e..00000000000 --- a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from "react"; - -import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { truncatePubkey } from "@/shared/lib/pubkey"; -import { InlineChip } from "@/shared/ui/InlineChip"; - -/** Visible send-state prefix for recipients kept in the composer address tray. */ -export function MessageAgentAddressPrefix({ - profiles, - pubkeys, -}: { - profiles?: UserProfileLookup; - pubkeys: readonly string[]; -}) { - return ( - <> - {pubkeys.map((pubkey) => { - const profile = profiles?.[pubkey]; - const label = - profile?.displayName?.trim() || - profile?.name?.trim() || - truncatePubkey(pubkey); - return ( - - {/* biome-ignore lint/a11y/useValidAriaRole: UserProfilePopover uses role for agent classification, not as an ARIA attribute. */} - - - {label} - - {" "} - - ); - })} - - ); -} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index e77e2e0d553..164e9446d4c 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,16 +1,13 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; -import { - useChannelLinks, - type ChannelSuggestion, -} from "@/features/messages/lib/useChannelLinks"; +import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; +import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; -import { - useEmojiAutocomplete, - type EmojiSuggestion, -} from "@/features/messages/lib/useEmojiAutocomplete"; +import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { findSpoileredImetaMediaUrls, @@ -27,15 +24,12 @@ import { useBackgroundMediaUpload, } from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { - getPersistentAgentAudienceScope, - usePersistentAgentAudience, -} from "@/features/messages/lib/persistentAgentAudience"; -import { - setKeepMentionedAgentsPinned, - useKeepMentionedAgentsPinned, -} from "@/features/messages/lib/autoPinMentionedAgentsPreference"; +import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { 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, @@ -45,23 +39,22 @@ 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"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; -import { MentionAutocomplete } from "./MentionAutocomplete"; +import { + MentionAutocomplete, + type MentionSuggestion, +} from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; -import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker"; -import { useAddressMentionPulse } from "./useAddressMentionPulse"; -import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut"; -import { useComposerMentionPicker } from "./useComposerMentionPicker"; -import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents"; +import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; -import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; @@ -92,7 +85,6 @@ function MessageComposerImpl({ onSend, placeholder, profiles, - recentMentionPubkeys, replyTarget = null, mediaController, showBackgroundUploadProgress = true, @@ -130,11 +122,13 @@ function MessageComposerImpl({ const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; const ownerPubkey = identityQuery.data?.pubkey ?? null; + const audienceThreadRootId = audienceContext?.threadRootId ?? null; const audienceScope = - audienceContext && channelId && ownerPubkey + audienceThreadRootId && channelId && ownerPubkey ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, + threadRootId: audienceThreadRootId, }) : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); @@ -147,7 +141,6 @@ function MessageComposerImpl({ } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { channelType, - recentMentionPubkeys, }); const channelLinks = useChannelLinks(); const customEmoji = useCustomEmoji(); @@ -206,17 +199,14 @@ function MessageComposerImpl({ const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); + // Sync lock: taken before any async send so rapid Enter can't double-submit. 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; @@ -233,6 +223,9 @@ function MessageComposerImpl({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); + // Set after `useLinkEditor` exists below; the editor's link-click handler + // delegates through this ref to break the hook ordering cycle (the editor + // needs `onEditLink`, but the link editor needs the editor's `richText`). const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); @@ -263,6 +256,8 @@ function MessageComposerImpl({ customEmoji, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { + // Never re-enter edit from an empty edit (e.g. image-only edit whose + // text body is empty) — `editTarget` means we're already editing. if (editTargetRef.current) return false; const handler = onEditLastOwnMessageRef.current; return handler ? handler() : false; @@ -276,12 +271,10 @@ function MessageComposerImpl({ contentRef.current = 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); + persistentMentionHydrationRef.current?.reconcile(text); if (text.trim().length > 0) { notifyTyping(); } @@ -297,41 +290,19 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentAudience = usePersistentAgentAudience(audienceScope); - const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); - const addressPulse = useAddressMentionPulse(); - const { - confirmationTitle: autoPinConfirmationTitle, - dismissConfirmation: dismissAutoPinConfirmation, - promoteExplicitlyAddressedAgents, - promoteMentionedAgents, - turnOffConfirmation: turnOffAutoPinConfirmation, - } = useAutoPinMentionedAgents({ + const persistentMentionHydration = usePersistentAgentMentionHydration({ audienceScope, - enabled: keepMentionedAgentsPinned, - getDisplayName: mentions.getMentionDisplayName, - onPulse: addressPulse.pulseOne, - onTurnOff: () => setKeepMentionedAgentsPinned(false), + hydrationKey: effectiveDraftKey, + initialAgentPubkeys: audienceContext?.initialAgentPubkeys, + isEditing: editTarget != null, + mentions, + richText, }); - 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 persistentAudience = persistentMentionHydration.audience; + const persistentMentionHydrationRef = React.useRef( + persistentMentionHydration, ); + persistentMentionHydrationRef.current = persistentMentionHydration; const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -341,23 +312,6 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, - onAddressedAgentsComposerCleared: (pubkeys) => - restoreAddressedAgentMentionsRef.current(pubkeys), - onAddressedAgentsSendFailed: addressPulse.shakeMany, - onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame( - () => { - restoreAddressedAgentMentionsFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys); - }, - ); - }, onPrepareSendChannel, onSendRef, richText, @@ -370,6 +324,18 @@ function MessageComposerImpl({ clearQueuedAttachments: media.clearQueuedAttachments, restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, + onSuccessfulExplicitAgentAudience: + persistentAudience.enabled && audienceContext && ownerPubkey + ? ({ channelId: successfulChannelId, ...promotion }) => { + const scope = getPersistentAgentAudienceScope({ + ownerPubkey, + channelId: successfulChannelId, + threadRootId: audienceThreadRootId, + }); + persistentAudience.promotePubkeys({ ...promotion, scope }); + } + : undefined, + resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, }); React.useEffect(() => { onDeferredEditPendingChange?.(isDeferredEditPending); @@ -385,6 +351,9 @@ function MessageComposerImpl({ queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; + // Strip the trailing `![image|video](url)` lines that correspond to + // imeta attachments — the user manages those via the attachments row, + // not via raw markdown in the editor. const editableImeta = restoreImetaMediaDisplayLabels( editTarget.body, editTarget.imetaMedia ?? [], @@ -392,12 +361,19 @@ function MessageComposerImpl({ const editableBody = stripImetaMediaLines(editTarget.body, editableImeta); setComposerContent(editableBody); richText.setContent(editableBody); + // Seed pending imeta with removable originals before saving the edit. + // New attachments can then be added through the same row. mentions.restoreDraftMentionRefs(editTarget.mentionRefs ?? []); media.setPendingImeta(editableImeta); media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); + // Defer focus to the next frame so it runs after any focus- + // restoration the trigger UI (e.g. the message-row context menu) + // fires on close. Without this, Radix-style focus-restoration races + // our call and leaves DOM focus on the message row — global keybinds + // like Delete then fire there instead of in the editor. `focusEnd` // also lands the caret at end of the loaded content. const rafId = requestAnimationFrame(() => richText.focusEnd()); return () => cancelAnimationFrame(rafId); @@ -425,7 +401,9 @@ function MessageComposerImpl({ if (!replyTarget || composerDisabled) return; richText.focusPreserve(); }, [composerDisabled, replyTarget, richText.focusPreserve]); + // ── Autofocus on mount / channel switch ───────────────────────────── useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); + // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). const applyAutocompleteEdit = React.useCallback( @@ -435,40 +413,21 @@ function MessageComposerImpl({ edit.replaceToOffset, edit.insertText, edit.customEmojiShortcode, - edit.preserveSelection, ); }, [richText.replacePlainTextRange], ); - const { - announcement: addressLockAnnouncement, - 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] : [], - }); + const applyMentionInsert = React.useCallback( + (suggestion: MentionSuggestion) => { + const { cursor } = richText.getPlainTextAndCursor(); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); }, - onPulseAddressLock: addressPulse.pulseOne, - profiles, - richText, - }); - restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; - syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; + [ + applyAutocompleteEdit, + mentions.insertMention, + richText.getPlainTextAndCursor, + ], + ); const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -527,23 +486,32 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - const openMentionPicker = useComposerMentionPicker({ - mentions, - richText, - setIsEmojiPickerOpen, - }); - const openMentionSettings = React.useCallback( - () => openMentionPicker(false), - [openMentionPicker], - ); - const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ - enabled: Boolean(audienceScope && editTarget == null), - lockedAgent: lockedAgents[0], - mentions, - onOpenPicker: openMentionPicker, - onSelect: selectMentionSuggestion, - onToggle: toggleAlwaysAddressAgent, - }); + const openMentionPicker = React.useCallback(() => { + if (!richText.editor) return; + const { text, cursor } = richText.getPlainTextAndCursor(); + // Check if there's already an @-query in progress + const beforeCursor = text.slice(0, cursor); + if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { + mentions.updateMentionQuery(text, cursor); + richText.focus(); + return; + } + // Insert @ at cursor + const previousChar = text.slice(0, cursor).slice(-1); + const prefix = + cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; + richText.editor.chain().focus().insertContent(prefix).run(); + setIsEmojiPickerOpen(false); + // Trigger mention detection after inserting @ + const { text: updatedText, cursor: updatedCursor } = + richText.getPlainTextAndCursor(); + mentions.updateMentionQuery(updatedText, updatedCursor); + }, [ + richText.editor, + richText.getPlainTextAndCursor, + richText.focus, + mentions.updateMentionQuery, + ]); const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); // Edit mode @@ -613,8 +581,8 @@ function MessageComposerImpl({ return; } isSubmitLockedRef.current = true; - setIsSubmitLocked(true); onPreparingMentionSendChange?.(true); + persistentMentionHydration.beginSubmit(); try { const preparedLinkPreviews = getReadyLinkPreviewTags().some( (tag) => tag[1] === "none", @@ -622,7 +590,6 @@ function MessageComposerImpl({ ? null : prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates()); await mentionSendFlow.sendMessageWithMentionFlow({ - addressedAgentPubkeys: persistentAudience.pubkeys, capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, @@ -636,10 +603,12 @@ function MessageComposerImpl({ recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, + audienceGeneration: persistentAudience.generation, + audienceRevision: audienceScope ? persistentAudience.revision : null, }); } finally { isSubmitLockedRef.current = false; - setIsSubmitLocked(false); + persistentMentionHydration.endSubmit(); onPreparingMentionSendChange?.(false); } }, [ @@ -666,7 +635,10 @@ function MessageComposerImpl({ syncComposerContentFromEditor, onCaptureSendContext, onPreparingMentionSendChange, - persistentAudience.pubkeys, + audienceScope, + persistentMentionHydration, + persistentAudience.generation, + persistentAudience.revision, isEditSubmissionLocked, effectiveDraftKey, mentions.getDraftMentionRefs, @@ -707,7 +679,6 @@ function MessageComposerImpl({ // handles autocomplete arrow/enter keys and Escape for edit mode. const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (handleAlwaysAddressShortcut(event)) return; // Let autocomplete handle keys first const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); if (emojiResult.handled) { @@ -726,7 +697,7 @@ function MessageComposerImpl({ const { handled, suggestion } = mentions.handleMentionKeyDown(event); if (handled) { if (suggestion) { - selectMentionSuggestion(suggestion); + applyMentionInsert(suggestion); } return; } @@ -750,25 +721,86 @@ function MessageComposerImpl({ } }, [ - handleAlwaysAddressShortcut, emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, - selectMentionSuggestion, + applyMentionInsert, linkEditor.isCardOpen, linkEditor.focusCardFirstControl, isDeferredEditPending, onCancelEdit, ], ); - useComposerPasteHandler({ - editor: richText.editor, - scrollToBottom: scrollComposerToBottom, - setPendingImeta: media.setPendingImeta, - uploadFile: media.uploadFile, - }); + // ── 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]); // ── Send button state ─────────────────────────────────────────────── const sendDisabled = composerDisabled || @@ -846,7 +878,6 @@ 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} @@ -886,21 +917,8 @@ function MessageComposerImpl({ } /> @@ -917,37 +935,26 @@ function MessageComposerImpl({ ) : null} {composerLinkPreviews} - - {addressLockAnnouncement} - {(media.pendingImeta.length > 0 || media.queuedAttachments.length > 0 || media.isUploading) && ( -
- {media.pendingImeta.length > 0 || - media.queuedAttachments.length > 0 || - media.isUploading ? ( - - ) : null} +
+
)} {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */} @@ -960,8 +967,6 @@ function MessageComposerImpl({
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 7b4675f12d5..e56d75b4214 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -25,7 +25,9 @@ export type MessageComposerEditTarget = { export type MessageComposerProps = { audienceContext?: { - type: "channel" | "thread"; + type: "thread"; + threadRootId: string; + initialAgentPubkeys?: readonly string[]; } | null; channelId?: string | null; channelName: string; @@ -95,8 +97,6 @@ export type MessageComposerProps = { ) => 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 6af8b6368b4..75243cc5afa 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -1,16 +1,11 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; -import { ALargeSmall, Paperclip, X } from "lucide-react"; +import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react"; import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { - type ComposerAddressAgent, - ComposerMentionButton, - ComposerSendButton, -} from "./ComposerAddressControls"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; import { FormattingToolbar } from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; @@ -21,13 +16,9 @@ const presenceSpring = { stiffness: 400, damping: 28, } as const; -const NO_ADDRESSED_AGENTS: readonly ComposerAddressAgent[] = []; -const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ - addressedAgents = NO_ADDRESSED_AGENTS, - autoPinConfirmationTitle, composerDisabled, editor, extraActions, @@ -38,21 +29,14 @@ export const MessageComposerToolbar = React.memo( isSending, isUploading, onCaptureSelection, - onAutoPinConfirmationDismiss, - onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, onLinkButton, onOpenMentionPicker, onPaperclip, - onRemoveAddressedAgent = ignoreAddressRemoval, - pulseVersionByPubkey, sendDisabled, - shakeVersionByPubkey, }: { - addressedAgents?: readonly ComposerAddressAgent[]; - autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -63,18 +47,13 @@ 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; onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; - onRemoveAddressedAgent?: (pubkey: string) => void; - pulseVersionByPubkey?: Readonly>; sendDisabled: boolean; - shakeVersionByPubkey?: Readonly>; }) { return (
@@ -182,19 +161,24 @@ export const MessageComposerToolbar = React.memo( exit={{ opacity: 0, x: -12 }} transition={presenceSpring} > - + {/* disableHoverableContent keeps tooltips from lingering over the editor. */} + + + + + Mention someone +
); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 0f1ab3c981d..a43036148ad 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { AlertTriangle } from "lucide-react"; + import { depthGuideActionsEqual, numberArrayEqual, @@ -57,9 +58,6 @@ import { MessageTimestamp } from "./MessageTimestamp"; import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs"; -import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys"; -import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -279,20 +277,6 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); - const addressedAgentPubkeys = React.useMemo(() => { - return getVisibleAgentAddressPubkeys( - message.body, - getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), - mentionPubkeysByName, - ); - }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); - const agentAddressPrefix = - addressedAgentPubkeys.length > 0 ? ( - - ) : undefined; const imetaByUrl = React.useMemo( () => (message.tags ? parseImetaTags(message.tags) : undefined), @@ -445,7 +429,6 @@ export const MessageRow = React.memo( messageId={message.id} linkPreviewsSuppressed={linkPreviewsSuppressed} linkPreviewTags={message.tags} - leadingInlineContent={agentAddressPrefix} onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 16ee26bcc79..4bd73737502 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,7 +1,11 @@ import * as React from "react"; import { ArrowDown } from "lucide-react"; +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro"; +import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { buildThreadSummaryFromVisibleEntries, getActiveContinuationDepths, @@ -106,7 +110,6 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { remove: boolean, ) => Promise; profiles?: UserProfileLookup; - recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -185,7 +188,6 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, - recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, @@ -494,6 +496,29 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); + const knownAgentPubkeys = useKnownAgentPubkeys(); + const initialAgentPubkeys = React.useMemo(() => { + if ( + !threadHead || + !currentPubkey || + normalizePubkey(threadHead.signerPubkey ?? threadHead.pubkey ?? "") !== + normalizePubkey(currentPubkey) + ) { + return []; + } + const { mentionPubkeysByName } = resolveMentionProps( + threadHead.tags, + profiles, + ); + if (!mentionPubkeysByName) return []; + + return orderMentionPubkeysByText( + threadHead.body, + mentionPubkeysByName, + (pubkey) => + knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, + ); + }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); const stableSendToChannel = useStableSendToChannel( channelId, threadHead, @@ -830,7 +855,11 @@ export function MessageThreadPanel({ > ", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); -}); - -afterEach(async () => { - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -test("agent picker preference skips people", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useMentionSelection } = await import( - "@/features/messages/lib/useMentionSelection" - ); - const view = renderHook( - ({ suggestions }) => useMentionSelection(suggestions), - { initialProps: { suggestions: [] } }, - ); - const suggestions = [ - { displayName: "Alice", pubkey: "person" }, - { displayName: "Agent Ada", isAgent: true, pubkey: "agent-a" }, - { displayName: "Bob", pubkey: "person-b" }, - { displayName: "Agent Bea", isAgent: true, pubkey: "agent-b" }, - ]; - - act(() => view.result.current.prepareSelectionPreference("first-agent")); - view.rerender({ suggestions }); - assert.equal(view.result.current.mentionSelectedIndex, 1); -}); - -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 selected = []; - const toggled = []; - const suggestion = { - displayName: "Agent Ada", - isAgent: true, - pubkey: "agent-a", - }; - const createEvent = () => ({ - altKey: false, - code: "KeyM", - ctrlKey: !isMacPlatform(), - key: "M", - metaKey: isMacPlatform(), - preventDefault() {}, - repeat: false, - shiftKey: true, - }); - const view = renderHook( - ({ isMentionOpen }) => - useAlwaysAddressShortcut({ - enabled: true, - mentions: { - getDefaultAgentSuggestion: () => suggestion, - isMentionOpen, - mentionSelectedIndex: 0, - suggestions: [suggestion], - }, - onOpenPicker: () => {}, - onSelect: (value) => selected.push(value), - onToggle: (value) => toggled.push(value), - }), - { initialProps: { isMentionOpen: false } }, - ); - - act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, []); - - view.rerender({ isMentionOpen: true }); - act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion]); - - act(() => assert.equal(view.result.current(createEvent()), true)); - 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/persistentAgentAudienceHosts.test.mjs b/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs index 26f666491c8..d6200f253e1 100644 --- a/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs +++ b/desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs @@ -16,12 +16,16 @@ test("supported conversation hosts opt into explicit audience contexts", async ( ], ); - assert.match(channelPane, /audienceContext=\{\{ type: "channel" \}\}/); + assert.doesNotMatch(channelPane, /audienceContext=/); assert.doesNotMatch(newMessage, /audienceContext=/); - assert.match(threadPanel, /audienceContext=\{\{ type: "thread" \}\}/); - assert.match(inboxDetail, /type: "thread"/); - assert.doesNotMatch(threadPanel, /audienceContext=\{[\s\S]*threadRootId/); - assert.doesNotMatch(inboxDetail, /audienceContext=\{[\s\S]*threadRootId/); + assert.match( + threadPanel, + /type: "thread"[\s\S]*threadRootId: threadHead\.id/, + ); + assert.match( + inboxDetail, + /type: "thread"[\s\S]*threadRootId: item\.conversationId/, + ); }); test("video review remains explicitly outside persistent audience routing", async () => { @@ -39,5 +43,5 @@ test("composer never derives audience context from draft keys", async () => { const composer = await source("./MessageComposer.tsx"); assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/); - assert.match(composer, /audienceContext && channelId && ownerPubkey/); + assert.match(composer, /audienceContext\?\.threadRootId/); }); diff --git a/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs b/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs deleted file mode 100644 index e4eea41ef54..00000000000 --- a/desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; - -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); -}); - -afterEach(async () => { - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -test("pulse versions restart per addressed agent", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAddressMentionPulse } = await import( - "./useAddressMentionPulse.ts" - ); - const { result } = renderHook(() => useAddressMentionPulse()); - - act(() => result.current.pulseMany(["AGENT-A", "agent-a", "agent-b"])); - assert.deepEqual(result.current.pulseVersionByPubkey, { - "agent-a": 1, - "agent-b": 1, - }); - - act(() => result.current.pulseOne("agent-a")); - assert.deepEqual(result.current.pulseVersionByPubkey, { - "agent-a": 2, - "agent-b": 1, - }); - - act(() => result.current.shakeMany(["AGENT-A", "agent-a", "agent-b"])); - assert.deepEqual(result.current.shakeVersionByPubkey, { - "agent-a": 1, - "agent-b": 1, - }); -}); diff --git a/desktop/src/features/messages/ui/useAddressMentionPulse.ts b/desktop/src/features/messages/ui/useAddressMentionPulse.ts deleted file mode 100644 index 55c157b979b..00000000000 --- a/desktop/src/features/messages/ui/useAddressMentionPulse.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; - -export function useAddressMentionPulse() { - const [pulseVersionByPubkey, setPulseVersionByPubkey] = React.useState< - Record - >({}); - const [shakeVersionByPubkey, setShakeVersionByPubkey] = React.useState< - Record - >({}); - const pulseMany = React.useCallback((pubkeys: readonly string[]) => { - setPulseVersionByPubkey((current) => { - const next = { ...current }; - for (const pubkey of new Set( - pubkeys.map((value) => value.toLowerCase()), - )) { - next[pubkey] = (next[pubkey] ?? 0) + 1; - } - return next; - }); - }, []); - const pulseOne = React.useCallback( - (pubkey: string) => pulseMany([pubkey]), - [pulseMany], - ); - const shakeMany = React.useCallback((pubkeys: readonly string[]) => { - setShakeVersionByPubkey((current) => { - const next = { ...current }; - for (const pubkey of new Set( - pubkeys.map((value) => value.toLowerCase()), - )) { - next[pubkey] = (next[pubkey] ?? 0) + 1; - } - return next; - }); - }, []); - - return { - pulseMany, - pulseOne, - pulseVersionByPubkey, - shakeMany, - shakeVersionByPubkey, - }; -} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs deleted file mode 100644 index 95e0af9e06b..00000000000 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ /dev/null @@ -1,634 +0,0 @@ -import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; - -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); -}); - -afterEach(async () => { - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -test("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" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - let cancelCount = 0; - const text = "@"; - const mentions = { - cancelMentionAutocomplete: () => { - cancelCount += 1; - }, - getDraftMentionRefs: () => [ - { - displayName: "Agent Ada", - pubkey: "agent-pubkey", - isAgent: true, - }, - ], - getMentionDisplayName: () => "Agent Ada", - isInlineMentionSelection: () => false, - isMentionOpen: true, - registerMentionPubkey: () => {}, - mentionStartIndex: text.lastIndexOf("@"), - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text, cursor: text.length }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.toggleAlwaysAddressAgent({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - 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"]); - assert.equal( - result.current.announcement, - "Automatically mentioning Agent Ada", - ); -}); - -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( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const removedPubkeys = []; - const pulsedPubkeys = []; - let cancelCount = 0; - const text = "Ask @Agent Ada later @"; - const mentions = { - cancelMentionAutocomplete: () => { - cancelCount += 1; - }, - getDraftMentionRefs: () => [ - { - displayName: "Agent Ada", - pubkey: "agent-pubkey", - isAgent: true, - }, - ], - getMentionDisplayName: () => "Agent Ada", - registerMentionPubkey: () => {}, - mentionStartIndex: text.lastIndexOf("@"), - }; - const audience = { - pubkeys: ["agent-pubkey"], - addPubkey: () => { - throw new Error("an addressed agent must not be added again"); - }, - removePubkey: (pubkey) => removedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text, cursor: text.length }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.toggleAlwaysAddressAgent({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 4, - replaceToOffset: 15, - insertText: "", - }, - ]); - assert.equal(cancelCount, 0); - assert.deepEqual(removedPubkeys, ["agent-pubkey"]); - assert.deepEqual(pulsedPubkeys, []); - assert.equal( - result.current.announcement, - "Stopped automatically mentioning Agent Ada", - ); -}); - -test("selecting an already addressed agent from the explicit picker pulses its badge", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, - insertMention: () => ({ - replaceFromOffset: 5, - replaceToOffset: 5, - insertText: "@Agent Ada ", - }), - mentionStartIndex: 5, - }; - const audience = { - pubkeys: ["agent-pubkey"], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "AGENT-PUBKEY", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - 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 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 = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - registerMentionPubkey: () => {}, - isInlineMentionSelection: () => true, - insertMention: () => ({ - replaceFromOffset: 5, - replaceToOffset: 6, - insertText: "@Agent Ada ", - }), - mentionStartIndex: 5, - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - // Selection intent comes from the mention picker, even if focus movement - // makes the editor text/cursor insufficient to re-detect the typed query. - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onAutoPinAgentMention: (suggestion) => - autoPinnedSuggestions.push(suggestion), - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - const suggestion = { - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }; - act(() => result.current.selectMentionSuggestion(suggestion)); - - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 5, - replaceToOffset: 6, - 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( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, - insertMention: () => ({ - replaceFromOffset: 5, - replaceToOffset: 5, - insertText: "@Agent Ada ", - }), - mentionStartIndex: 5, - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 5, - replaceToOffset: 5, - insertText: "@Agent Ada ", - }, - ]); - assert.deepEqual(addedPubkeys, ["agent-pubkey"]); - assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); - assert.equal( - result.current.announcement, - "Automatically mentioning Agent Ada", - ); -}); - -test("selecting an explicitly unpinned agent inserts a mention until send", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const removedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [ - { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, - ], - getMentionDisplayName: () => "Agent Ada", - registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, - insertMention: () => ({ - replaceFromOffset: 0, - replaceToOffset: 0, - insertText: "@Agent Ada ", - }), - mentionStartIndex: 0, - }; - const richText = { - getPlainTextAndCursor: () => ({ - text: "@Agent Ada keep this authored text", - cursor: 35, - }), - }; - const { result, rerender } = renderHook( - ({ pubkeys }) => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience: { - pubkeys, - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - removePubkey: (pubkey) => removedPubkeys.push(pubkey), - }, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - { initialProps: { pubkeys: ["agent-pubkey"] } }, - ); - - act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); - assert.deepEqual(appliedEdits, []); - rerender({ pubkeys: [] }); - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(removedPubkeys, ["agent-pubkey"]); - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 0, - replaceToOffset: 0, - insertText: "@Agent Ada ", - }, - ]); - assert.deepEqual(addedPubkeys, []); - assert.deepEqual(pulsedPubkeys, []); -}); - -test("an addressed agent keeps its resolved name while mention state clears during send", async () => { - const { renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - let displayName = "Agent Ada"; - const mentions = { - getMentionDisplayName: () => displayName, - }; - const audience = { - pubkeys: ["agent-pubkey"], - }; - const { result, rerender } = renderHook( - ({ profiles }) => - useAgentAddressLockPicker({ - applyAutocompleteEdit: () => {}, - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: () => {}, - profiles, - richText: {}, - }), - { initialProps: { profiles: {} } }, - ); - - assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); - - displayName = null; - rerender({ profiles: {} }); - - assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); -}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts deleted file mode 100644 index 17883900ecb..00000000000 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ /dev/null @@ -1,388 +0,0 @@ -import * as React from "react"; - -import { getMentionOffsets } from "@/features/messages/lib/hasMention"; -import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { - AutocompleteEdit, - UseRichTextEditorResult, -} from "@/features/messages/lib/useRichTextEditor"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import type { ComposerAddressAgent } from "./ComposerAddressControls"; -import type { MentionSuggestion } from "./MentionAutocomplete"; - -function buildMentionRemovalEdits( - text: string, - displayNames: readonly string[], - queryRange?: { start: number; end: number }, -): AutocompleteEdit[] { - const ranges = displayNames.flatMap((displayName) => - getMentionOffsets(text, displayName).map((start) => { - let end = start + `@${displayName}`.length; - if (text[end] === " ") end += 1; - return { start, end }; - }), - ); - if (queryRange) { - ranges.push({ - start: Math.max(0, Math.min(queryRange.start, text.length)), - end: Math.max(0, Math.min(queryRange.end, text.length)), - }); - } - - const merged = ranges - .filter(({ start, end }) => start < end) - .sort((left, right) => left.start - right.start) - .reduce>((result, range) => { - const previous = result.at(-1); - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - result.push({ ...range }); - } - return result; - }, []); - - return merged.reverse().map(({ start, end }) => ({ - replaceFromOffset: start, - replaceToOffset: end, - insertText: "", - })); -} - -export function useAgentAddressLockPicker({ - applyAutocompleteEdit, - audience, - audienceScope, - mentions, - onAddressAgentMention, - onAutoPinAgentMention, - onPulseAddressLock, - profiles, - richText, -}: { - applyAutocompleteEdit: (edit: AutocompleteEdit) => void; - audience: ReturnType; - audienceScope: string | null; - mentions: UseMentionsResult; - onAddressAgentMention?: (suggestion: MentionSuggestion) => void; - onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; - onPulseAddressLock: (pubkey: string) => void; - profiles?: UserProfileLookup; - richText: UseRichTextEditorResult; -}) { - const lockedAgentPubkeys = React.useMemo( - () => new Set(audience.pubkeys), - [audience.pubkeys], - ); - const unpinnedAgentPubkeysRef = React.useRef(new Set()); - const unpinnedAudienceScopeRef = React.useRef(audienceScope); - if (unpinnedAudienceScopeRef.current !== audienceScope) { - unpinnedAudienceScopeRef.current = audienceScope; - 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( - () => - audience.pubkeys.map((pubkey) => { - const normalized = normalizePubkey(pubkey); - const profile = profiles?.[normalized]; - const resolvedDisplayName = - profile?.displayName?.trim() || - profile?.name?.trim() || - profile?.nip05Handle?.trim() || - mentions.getMentionDisplayName(normalized)?.trim(); - if (resolvedDisplayName) { - lockedAgentNamesRef.current.set(normalized, resolvedDisplayName); - } - return { - pubkey: normalized, - displayName: - resolvedDisplayName ?? - lockedAgentNamesRef.current.get(normalized) ?? - truncatePubkey(normalized), - avatarUrl: profile?.avatarUrl ?? null, - }; - }), - [audience.pubkeys, mentions.getMentionDisplayName, profiles], - ); - 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); - } - } - visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; - }, - [ - audience.removePubkey, - audienceScope, - lockedAgentPubkeys, - mentions.getDraftMentionRefs, - ], - ); - - const removeAddressedAgent = React.useCallback( - (pubkey: string) => { - const normalized = normalizePubkey(pubkey); - if (!audienceScope || !normalized) return; - unpinnedAgentPubkeysRef.current.add(normalized); - audience.removePubkey(normalized); - }, - [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)) { - removeAddressedAgentMentions(pubkey); - setAnnouncement( - `Stopped automatically mentioning ${suggestion.displayName}`, - ); - } else { - unpinnedAgentPubkeysRef.current.delete(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 && mentions.isInlineMentionSelection()) { - const { text, cursor } = richText.getPlainTextAndCursor(); - const activeMention = detectPrefixQuery("@", text, cursor, [ - suggestion.displayName.toLowerCase(), - ]); - const queryStart = Math.max( - 0, - Math.min( - activeMention?.startIndex ?? mentions.mentionStartIndex, - text.length, - ), - ); - applyAutocompleteEdit({ - replaceFromOffset: queryStart, - replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)), - insertText: "", - }); - mentions.openMentionPicker(queryStart, "preserve"); - } - }, - [ - applyAutocompleteEdit, - audience.addPubkey, - audienceScope, - lockedAgentPubkeys, - mentions.isInlineMentionSelection, - mentions.isMentionOpen, - mentions.mentionStartIndex, - mentions.openMentionPicker, - mentions.registerMentionPubkey, - onAddressAgentMention, - onPulseAddressLock, - removeAddressedAgentMentions, - richText.getPlainTextAndCursor, - trackMentionAddressedAgent, - ], - ); - - const selectMentionSuggestion = React.useCallback( - (suggestion: MentionSuggestion) => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (suggestion.isAgent && pubkey && audienceScope) { - const { cursor } = richText.getPlainTextAndCursor(); - const wasUnpinned = - !lockedAgentPubkeys.has(pubkey) && - unpinnedAgentPubkeysRef.current.has(pubkey); - if (mentions.isInlineMentionSelection() || wasUnpinned) { - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); - trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion); - return; - } - - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (!lockedAgentPubkeys.has(pubkey)) { - trackMentionAddressedAgent(pubkey); - if (onAddressAgentMention) { - onAddressAgentMention(suggestion); - } else { - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); - } - setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); - } else { - onPulseAddressLock(pubkey); - } - return; - } - - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - }, - [ - applyAutocompleteEdit, - audience.addPubkey, - audienceScope, - 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, - ], - ); - - return { - announcement, - 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 deleted file mode 100644 index ed92e7b3f0f..00000000000 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react"; - -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; -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 { - getDefaultAgentSuggestion, - isMentionOpen, - mentionSelectedIndex, - suggestions, - } = mentions; - return React.useCallback( - (event: React.KeyboardEvent): boolean => { - if ( - !enabled || - event.code !== "KeyM" || - !hasPrimaryShortcutModifier(event) || - event.altKey || - !event.shiftKey - ) { - return false; - } - - event.preventDefault(); - if (event.repeat) return true; - const suggestion = isMentionOpen - ? suggestions[mentionSelectedIndex] - : lockedAgent - ? { ...lockedAgent, isAgent: true } - : getDefaultAgentSuggestion(); - if (!suggestion?.isAgent || !suggestion.pubkey) { - if (!isMentionOpen) onOpenPicker(false); - return true; - } - 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 deleted file mode 100644 index d0557567fdd..00000000000 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as React from "react"; - -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; - onPulse: (pubkey: string) => void; - onTurnOff: () => void; -}; - -export function useAutoPinMentionedAgents({ - audienceScope, - enabled, - getDisplayName, - onPulse, - onTurnOff, -}: Options) { - 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 = audienceScope - ? getPersistentAgentAudienceRevision(audienceScope) - : 0, - pubkeys, - requirePreference, - }: { - expectedRevision?: number; - pubkeys: readonly string[]; - requirePreference: boolean; - }) => { - if (!audienceScope || (requirePreference && !enabled)) return; - const normalizedPubkeys = [ - ...new Set(pubkeys.map(normalizePubkey)), - ].filter(Boolean); - const promotion = promotePersistentAgentAudienceIfUnchanged({ - expectedRevision, - pubkeys: normalizedPubkeys, - scope: audienceScope, - }); - if (promotion === null) return; - const { promotedPubkeys, revision } = promotion; - for (const pubkey of promotedPubkeys) onPulse(pubkey); - - const displayName = - promotedPubkeys.length === 1 - ? getDisplayName(promotedPubkeys[0])?.trim() - : null; - const title = displayName - ? `${displayName} will be mentioned automatically` - : promotedPubkeys.length === 1 - ? "Agent will be mentioned automatically" - : `${promotedPubkeys.length} agents will be mentioned automatically`; - setConfirmation({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - title, - }); - }, - [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/useComposerMentionPicker.ts b/desktop/src/features/messages/ui/useComposerMentionPicker.ts deleted file mode 100644 index 1a13aa98586..00000000000 --- a/desktop/src/features/messages/ui/useComposerMentionPicker.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react"; - -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; - -export function useComposerMentionPicker({ - mentions, - richText, - setIsEmojiPickerOpen, -}: { - mentions: UseMentionsResult; - richText: UseRichTextEditorResult; - setIsEmojiPickerOpen: (open: boolean) => void; -}) { - const { - cancelMentionAutocomplete, - isMentionOpen, - openMentionPicker, - updateMentionQuery, - } = mentions; - const { editor, focus, getPlainTextAndCursor } = richText; - return React.useCallback( - (insertTrigger = true) => { - if (!editor) return; - const { text, cursor } = getPlainTextAndCursor(); - if (!insertTrigger) { - if (isMentionOpen) { - cancelMentionAutocomplete(); - setIsEmojiPickerOpen(false); - focus(); - return; - } - openMentionPicker(cursor, "first-agent"); - setIsEmojiPickerOpen(false); - focus(); - return; - } - const beforeCursor = text.slice(0, cursor); - if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { - updateMentionQuery(text, cursor); - focus(); - return; - } - const previousChar = text.slice(0, cursor).slice(-1); - const prefix = - cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; - editor.chain().focus().insertContent(prefix).run(); - setIsEmojiPickerOpen(false); - const updated = getPlainTextAndCursor(); - updateMentionQuery(updated.text, updated.cursor); - }, - [ - cancelMentionAutocomplete, - editor, - focus, - getPlainTextAndCursor, - isMentionOpen, - openMentionPicker, - setIsEmojiPickerOpen, - updateMentionQuery, - ], - ); -} diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts deleted file mode 100644 index 8e56da071a9..00000000000 --- a/desktop/src/features/messages/ui/useComposerPasteHandler.ts +++ /dev/null @@ -1,73 +0,0 @@ -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.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs deleted file mode 100644 index f45f63be27a..00000000000 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { mergeMentionRecipients } from "./useMentionSendFlow.helpers.ts"; - -test("address-locked agents join explicit mentions without duplicating recipients", () => { - const explicit = ["A".repeat(64), "b".repeat(64)]; - const locked = ["a".repeat(64), "C".repeat(64)]; - - assert.deepEqual(mergeMentionRecipients(explicit, locked), [ - "a".repeat(64), - "b".repeat(64), - "c".repeat(64), - ]); -}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index ab7d8c2f4d6..4bd87c15d64 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -12,8 +12,6 @@ import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { - addressedAgentPubkeys: string[]; - inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { parentEventId: string | null; @@ -33,10 +31,12 @@ export type PendingNonMemberMentionSend = { sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; savedMentionRefs: DraftMentionRef[]; + audienceGeneration: number; + audienceRevision: number | null; + explicitAgentPubkeys: string[]; }; export type SendMessageWithMentionFlowInput = { - addressedAgentPubkeys?: readonly string[]; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; @@ -47,6 +47,8 @@ export type SendMessageWithMentionFlowInput = { recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; + audienceGeneration?: number; + audienceRevision?: number | null; }; export async function resolvePreviewTags( @@ -87,16 +89,6 @@ export function uniqueNormalizedPubkeys(pubkeys: Iterable) { return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); } -export function mergeMentionRecipients( - explicitMentionPubkeys: Iterable, - addressedAgentPubkeys: Iterable, -) { - return uniqueNormalizedPubkeys([ - ...explicitMentionPubkeys, - ...addressedAgentPubkeys, - ]); -} - export function isManagedAgentRunning(agent: ManagedAgent) { return agent.status === "running" || agent.status === "deployed"; } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 484b4512070..19900a9fcba 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -16,24 +16,31 @@ import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; +import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; 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 { AcpRuntime, ManagedAgent } from "@/shared/api/types"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { getErrorMessage, isManagedAgentRunning, isProviderBackedAgent, - mergeMentionRecipients, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, @@ -41,9 +48,50 @@ import { resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; -import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; - +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; + 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< + UseRichTextEditorResult, + "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" + >; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; + onSuccessfulExplicitAgentAudience?: (audience: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => void; + resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; +}; export function useMentionSendFlow({ channelId, channelLinks, @@ -54,9 +102,6 @@ export function useMentionSendFlow({ emojiAutocomplete, mentions, onPrepareSendChannel, - onAddressedAgentsComposerCleared, - onAddressedAgentsSendFailed, - onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -66,6 +111,8 @@ export function useMentionSendFlow({ clearQueuedAttachments, restoreQueuedAttachments, setSpoileredAttachmentUrls, + onSuccessfulExplicitAgentAudience, + resolvePostSendContent, }: UseMentionSendFlowOptions) { const [pendingNonMemberSend, setPendingNonMemberSend] = React.useState(null); @@ -283,31 +330,39 @@ export function useMentionSendFlow({ provisionPersonaAgentMutation, ], ); - const clearComposer = React.useCallback(() => { - setPendingNonMemberSend(null); - setNonMemberPromptError(null); - setContent(""); - contentRef.current = ""; - richText.clearContent(); - setPendingImeta([]); - clearQueuedAttachments(); - setSpoileredAttachmentUrls?.(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - }, [ - channelLinks.clearChannels, - contentRef, - emojiAutocomplete.clearEmojis, - mentions.clearMentions, - richText.clearContent, - setContent, - setIsEmojiPickerOpen, - setPendingImeta, - clearQueuedAttachments, - setSpoileredAttachmentUrls, - ]); + const clearComposer = React.useCallback( + (postSendContent = "") => { + setPendingNonMemberSend(null); + setNonMemberPromptError(null); + setContent(postSendContent); + contentRef.current = postSendContent; + if (postSendContent) { + richText.restorePlainTextAndFocusEnd(postSendContent); + mentions.cancelMentionAutocomplete(); + } else richText.clearContent(); + setPendingImeta([]); + clearQueuedAttachments(); + setSpoileredAttachmentUrls?.(new Set()); + if (!postSendContent) mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, + [ + channelLinks.clearChannels, + contentRef, + emojiAutocomplete.clearEmojis, + mentions.cancelMentionAutocomplete, + mentions.clearMentions, + richText.clearContent, + richText.restorePlainTextAndFocusEnd, + setContent, + setIsEmojiPickerOpen, + setPendingImeta, + clearQueuedAttachments, + setSpoileredAttachmentUrls, + ], + ); React.useEffect(() => { if (previousChannelIdRef.current === channelId) { return; @@ -349,88 +404,12 @@ export function useMentionSendFlow({ draft.queuedAttachments, ); }; - const persistCanceledDraft = () => { - if (isSendCancelled() || !draft.recoveryDraftKey) return; - const existing = drafts.loadDraft(draft.recoveryDraftKey); - if ( - existing && - (existing.content !== draft.savedContent || - existing.channelId !== - (draft.capturedChannelId ?? draft.recoveryDraftKey) || - JSON.stringify(existing.pendingImeta) !== - JSON.stringify(draft.savedImeta) || - JSON.stringify(existing.spoileredAttachmentUrls) !== - JSON.stringify([...draft.savedSpoileredAttachmentUrls])) - ) { - return; - } - drafts.persistDraft( - draft.recoveryDraftKey, - draft.savedContent, - draft.capturedChannelId ?? draft.recoveryDraftKey, - draft.savedImeta, - [...draft.savedSpoileredAttachmentUrls], - draft.savedMentionRefs, - ); - }; - let composerCleared = false; - let optimisticComposerContent = ""; - const restoreComposerAfterFailure = () => { - if (!composerCleared) return; - composerCleared = false; - persistCanceledDraft(); - const canAnimateCurrentComposer = - isMountedRef.current && - (draft.capturedChannelId === channelIdRef.current || - channelIdRef.current === null); - if ( - canAnimateCurrentComposer && - draft.addressedAgentPubkeys.length > 0 - ) { - onAddressedAgentsSendFailed?.(draft.addressedAgentPubkeys); - } - const canRestoreCurrentComposer = - canAnimateCurrentComposer && - contentRef.current.trim() === optimisticComposerContent.trim() && - !hasUnsavedMedia(); - if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { - saveQueuedAttachmentsForDraft( - draft.recoveryDraftKey, - draft.queuedAttachments, - ); - } - if (!canRestoreCurrentComposer) { - return; - } - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - restoreQueuedAttachments(draft.queuedAttachments); - mentions.restoreDraftMentionRefs(draft.savedMentionRefs); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); - }; - if ( - draft.capturedChannelId === channelIdRef.current || - channelIdRef.current === null - ) { - clearComposer(); - if (draft.addressedAgentPubkeys.length > 0) { - optimisticComposerContent = - onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? - ""; - contentRef.current = optimisticComposerContent; - } - composerCleared = true; - } let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); const readyAgentPubkeys = new Set( @@ -439,7 +418,7 @@ export function useMentionSendFlow({ ), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -462,9 +441,9 @@ export function useMentionSendFlow({ let sendChannelId = draft.capturedChannelId; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; if (!sendChannelId) { - return restoreComposerAfterFailure(); + return; } if (!isMountedRef.current) { persistPreflightDraft(); @@ -479,7 +458,7 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -493,7 +472,7 @@ export function useMentionSendFlow({ )}`; setNonMemberPromptError(message); toast.error(message); - return restoreComposerAfterFailure(); + return; } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { @@ -501,19 +480,76 @@ export function useMentionSendFlow({ channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; } catch (error) { - if (isSendCancelled()) return restoreComposerAfterFailure(); + if (isSendCancelled()) return; const message = `Could not add mentioned agent to the Huddle: ${getErrorMessage( error, "Huddle enrollment failed.", )}`; setNonMemberPromptError(message); toast.error(message); - return restoreComposerAfterFailure(); + return; } } + const effectiveExplicitAgentPubkeys = + filterEffectiveExplicitAgentPubkeys( + draft.explicitAgentPubkeys, + mentionPubkeys, + ); const send = onSendRef.current; + const persistCanceledDraft = () => { + if (isSendCancelled() || !draft.recoveryDraftKey) return; + const existing = drafts.loadDraft(draft.recoveryDraftKey); + if ( + existing && + (existing.content !== draft.savedContent || + existing.channelId !== + (draft.capturedChannelId ?? draft.recoveryDraftKey) || + JSON.stringify(existing.pendingImeta) !== + JSON.stringify(draft.savedImeta) || + JSON.stringify(existing.spoileredAttachmentUrls) !== + JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + ) { + return; + } + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, + ); + }; + const restoreComposerAfterFailure = () => { + if (isSendCancelled()) return; + persistCanceledDraft(); + const canRestoreCurrentComposer = + isMountedRef.current && + (draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null) && + contentRef.current.trim().length === 0 && + !hasUnsavedMedia(); + if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + } + if (!canRestoreCurrentComposer) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + mentions.restoreDraftMentionRefs(draft.savedMentionRefs); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; const finishSend = async ( uploaded: ImetaMedia[], signal?: AbortSignal, @@ -540,41 +576,27 @@ export function useMentionSendFlow({ const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); if (signal?.aborted || isSendCancelled()) return; - const finalTagsWithAgentAddress = [ - ...finalOutgoingTags, - ...buildAgentAddressMentionTags( - draft.addressedAgentPubkeys, + const revalidatedExplicitAgentPubkeys = + filterEffectiveExplicitAgentPubkeys( + draft.explicitAgentPubkeys, revalidatedMentionPubkeys, - ), - ]; + ); await send( finalContent, revalidatedMentionPubkeys, - finalTagsWithAgentAddress, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, draft.preparedLinkPreviews != null, ); if (signal?.aborted || isSendCancelled()) return; - const sentMentionPubkeys = new Set( - revalidatedMentionPubkeys.map(normalizePubkey), - ); - 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 (revalidatedExplicitAgentPubkeys.length > 0) { + onSuccessfulExplicitAgentAudience?.({ + channelId: sendChannelId ?? draft.capturedChannelId ?? "", + expectedGeneration: draft.audienceGeneration, + expectedRevision: draft.audienceRevision, + explicitAgentPubkeys: revalidatedExplicitAgentPubkeys, + }); } if (draft.sentDraftKey) { drafts.markDraftSent( @@ -587,18 +609,12 @@ 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) => { @@ -606,18 +622,22 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); - settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); - settleUpload(); }, }); if (!uploadStarted) { - settleUpload(); - return restoreComposerAfterFailure(); + return; } - await uploadSettled; + } + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + clearComposer( + resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + ); } if (!preparedUpload) { try { @@ -626,9 +646,6 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); } } - } catch (error) { - restoreComposerAfterFailure(); - throw error; } finally { if (draft.preparedLinkPreviews) { activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); @@ -649,11 +666,10 @@ export function useMentionSendFlow({ getManagedAgentsByPubkey, mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, - onAddressedAgentsComposerCleared, - onAddressedAgentsSendFailed, - onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, + onSuccessfulExplicitAgentAudience, + resolvePostSendContent, richText.setContent, setContent, setPendingImeta, @@ -666,7 +682,6 @@ export function useMentionSendFlow({ ); const sendMessageWithMentionFlow = React.useCallback( async ({ - addressedAgentPubkeys = [], capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -677,6 +692,8 @@ export function useMentionSendFlow({ recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, + audienceGeneration = 0, + audienceRevision = null, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { return; @@ -696,11 +713,7 @@ export function useMentionSendFlow({ isThreadReply: capturedThreadContext != null, channelType, extractMentionPersonas: mentions.extractMentionPersonas, - extractMentionPubkeys: (text) => - mergeMentionRecipients( - mentions.extractMentionPubkeys(text), - addressedAgentPubkeys, - ), + extractMentionPubkeys: mentions.extractMentionPubkeys, isAgentPubkey: mentions.isAgentPubkey, hasResolvedMembers: mentions.hasResolvedMembers, memberPubkeys: mentions.memberPubkeys, @@ -742,10 +755,12 @@ export function useMentionSendFlow({ ...mentions.extractMentionPubkeys(trimmed), ...createdPersonaAgentPubkeys, ]); - const pubkeys = mergeMentionRecipients( - explicitMentionPubkeys, - addressedAgentPubkeys, + const explicitAgentPubkeys = explicitMentionPubkeys.filter( + (pubkey) => + mentions.isAgentPubkey(pubkey) || + createdPersonaAgentPubkeySet.has(pubkey), ); + const pubkeys = explicitMentionPubkeys; const outgoingTags = [ ...buildCustomEmojiTags(trimmed, customEmoji), ...linkPreviewTags, @@ -763,6 +778,7 @@ export function useMentionSendFlow({ !mentions.isManagedAgentPubkey(pubkey) && !createdPersonaAgentPubkeySet.has(normalizePubkey(pubkey)), ); + if (promptNonMemberPubkeys.length > 0) { try { const managedAgentsByPubkey = await getManagedAgentsByPubkey(); @@ -772,14 +788,8 @@ export function useMentionSendFlow({ ); } catch {} } - const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); + const pendingDraft: PendingNonMemberMentionSend = { - addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( - savedMentionRefs - .filter((ref) => ref.isAgent) - .map((ref) => ref.pubkey), - ), capturedChannelId: effectiveChannelId, capturedThreadContext, trimmed, @@ -798,13 +808,18 @@ export function useMentionSendFlow({ savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, recoveryDraftKey, - savedMentionRefs, + savedMentionRefs: mentions.getDraftMentionRefs(trimmed), + audienceGeneration, + audienceRevision, + explicitAgentPubkeys, }; + if (promptNonMemberPubkeys.length > 0) { setNonMemberPromptError(null); setPendingNonMemberSend(pendingDraft); return; } + sendPromoted = true; await completeSend(pendingDraft, pubkeys); } finally { @@ -837,13 +852,16 @@ export function useMentionSendFlow({ ); const pendingNonMemberNames = React.useMemo(() => { if (!pendingNonMemberSend) return []; + return pendingNonMemberSend.nonMemberPubkeys.map( (pubkey) => mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); + const handleSendWithoutInviting = React.useCallback(() => { if (!pendingNonMemberSend) return; + const nonMemberPubkeys = new Set( pendingNonMemberSend.nonMemberPubkeys.map((pubkey) => normalizePubkey(pubkey), @@ -892,12 +910,14 @@ export function useMentionSendFlow({ if (managedAgentsByPubkey.has(pubkey)) { continue; } + if (mentions.isAgentPubkey(pubkey)) { relayAgentPubkeys.push(pubkey); } else { peoplePubkeys.push(pubkey); } } + const errors: string[] = []; if (peoplePubkeys.length > 0) { const result = await addMembersMutation.mutateAsync({ @@ -907,6 +927,7 @@ export function useMentionSendFlow({ }); errors.push(...result.errors.map((error) => error.error)); } + if (relayAgentPubkeys.length > 0) { const result = await addMembersMutation.mutateAsync({ channelId: pendingNonMemberSend.capturedChannelId ?? undefined, @@ -915,10 +936,12 @@ export function useMentionSendFlow({ }); errors.push(...result.errors.map((error) => error.error)); } + if (errors.length > 0) { setNonMemberPromptError(errors.join("; ")); return; } + await completeSend( { ...pendingNonMemberSend, @@ -942,6 +965,7 @@ export function useMentionSendFlow({ mentions.revalidateMentionPubkeys, pendingNonMemberSend, ]); + const dismissNonMemberPrompt = React.useCallback(() => { setPendingNonMemberSend(null); setNonMemberPromptError(null); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts deleted file mode 100644 index fe16ba2f63a..00000000000 --- a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts +++ /dev/null @@ -1,51 +0,0 @@ -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; - 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/features/messages/ui/usePersistentAgentMentionHydration.ts b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts new file mode 100644 index 00000000000..d2e0d4390e1 --- /dev/null +++ b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts @@ -0,0 +1,175 @@ +import * as React from "react"; + +import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; + +export function usePersistentAgentMentionHydration({ + audienceScope, + hydrationKey, + initialAgentPubkeys, + isEditing, + mentions, + richText, +}: { + audienceScope: string | null; + hydrationKey: string | null | undefined; + initialAgentPubkeys?: readonly string[]; + isEditing: boolean; + mentions: UseMentionsResult; + richText: UseRichTextEditorResult; +}) { + const audience = usePersistentAgentAudience(audienceScope); + const audienceRef = React.useRef(audience); + audienceRef.current = audience; + const scopeRef = React.useRef(audienceScope); + scopeRef.current = audienceScope; + const isEditingRef = React.useRef(isEditing); + isEditingRef.current = isEditing; + React.useEffect(() => { + if (!audienceScope || !initialAgentPubkeys) return; + audience.initialize(initialAgentPubkeys); + }, [audience.initialize, audienceScope, initialAgentPubkeys]); + const isRestoringRef = React.useRef(false); + const isSubmittingRef = React.useRef(false); + const cancelHydrationAutocompleteRef = React.useRef(false); + const hydratedRef = React.useRef(false); + + const hydrate = React.useCallback(() => { + const capturedScope = audienceScope; + if ( + !audience.enabled || + !capturedScope || + isEditingRef.current || + audience.pubkeys.length === 0 + ) { + hydratedRef.current = true; + return; + } + isRestoringRef.current = true; + const current = richText.getPlainTextAndCursor().text; + const targets = audience.pubkeys + .map((pubkey) => ({ + pubkey, + displayName: mentions.getMentionDisplayName(pubkey), + })) + .filter((target): target is { pubkey: string; displayName: string } => + Boolean(target.displayName), + ); + for (const target of targets) + mentions.registerMentionPubkey(target.displayName, target.pubkey, { + isAgent: true, + }); + if (scopeRef.current !== capturedScope) { + isRestoringRef.current = false; + return; + } + const present = new Set(mentions.extractMentionPubkeys(current)); + let prefixLength = 0; + for (const target of targets.filter( + (candidate) => !present.has(candidate.pubkey), + )) { + if (scopeRef.current !== capturedScope) break; + const edit = mentions.insertResolvedMention({ + ...target, + isAgent: true, + replaceFromOffset: prefixLength, + replaceToOffset: prefixLength, + }); + cancelHydrationAutocompleteRef.current = true; + richText.replacePlainTextRange( + edit.replaceFromOffset, + edit.replaceToOffset, + edit.insertText, + ); + prefixLength += edit.insertText.length; + } + hydratedRef.current = scopeRef.current === capturedScope; + isRestoringRef.current = false; + if (cancelHydrationAutocompleteRef.current) { + cancelHydrationAutocompleteRef.current = false; + // Hydration is a programmatic transition, not an authored query. Cancel + // only when its editor updates actually scheduled autocomplete work. + mentions.cancelMentionAutocomplete(); + } + }, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]); + + const reconcile = React.useCallback( + (text: string) => { + if ( + !hydratedRef.current || + isRestoringRef.current || + isSubmittingRef.current || + isEditingRef.current + ) + return; + const present = new Set(mentions.extractMentionPubkeys(text)); + for (const pubkey of audienceRef.current.pubkeys) { + if (!present.has(pubkey)) audienceRef.current.removePubkey(pubkey); + } + }, + [mentions.extractMentionPubkeys], + ); + + const hydrateRef = React.useRef(hydrate); + hydrateRef.current = hydrate; + const scheduleHydration = React.useCallback( + (cancelAutocomplete = false) => + requestAnimationFrame(() => { + hydrateRef.current(); + if (cancelAutocomplete) mentions.cancelMentionAutocomplete(); + }), + [mentions.cancelMentionAutocomplete], + ); + React.useEffect(() => { + void hydrationKey; + hydratedRef.current = false; + const frame = scheduleHydration(); + return () => cancelAnimationFrame(frame); + }, [hydrationKey, scheduleHydration]); + + const resolvePostSendContent = React.useCallback( + (explicitAgentPubkeys: string[]) => { + if (!audience.enabled || !audienceScope || isEditingRef.current) + return ""; + const orderedPubkeys = [ + ...new Set([...explicitAgentPubkeys, ...audience.pubkeys]), + ]; + const targets = orderedPubkeys + .map((pubkey) => ({ + pubkey, + displayName: mentions.getMentionDisplayName(pubkey), + })) + .filter((target): target is { pubkey: string; displayName: string } => + Boolean(target.displayName), + ); + mentions.clearMentions(); + for (const target of targets) { + mentions.registerMentionPubkey(target.displayName, target.pubkey, { + isAgent: true, + }); + } + isRestoringRef.current = true; + hydratedRef.current = true; + return ( + targets.map((target) => `@${target.displayName}`).join(" ") + + (targets.length > 0 ? " " : "") + ); + }, + [audience.enabled, audience.pubkeys, audienceScope, mentions], + ); + + return { + audience, + beginSubmit: () => { + isSubmittingRef.current = true; + }, + endSubmit: () => { + isSubmittingRef.current = false; + scheduleHydration(true); + }, + reconcile, + resolvePostSendContent, + scheduleHydration, + }; +} diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx index 50538868d05..7307ff5df05 100644 --- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx @@ -1,21 +1,10 @@ import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; -import { - setKeepMentionedAgentsPinned, - useKeepMentionedAgentsPinned, -} from "@/features/messages/lib/autoPinMentionedAgentsPreference"; -import { Switch } from "@/shared/ui/switch"; import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; -import { - SettingsOptionGroup, - SettingsOptionGroupList, - SettingsOptionRow, -} from "./SettingsOptionGroup"; +import { SettingsOptionGroupList } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; export function AgentsSettingsPanel() { - const automaticallyMentionAgents = useKeepMentionedAgentsPinned(); - return (
- - -
- -

- After you mention them once -

-
- -
-
diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index 7844d1f8f86..9f46f0b5c85 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,10 +1,15 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { + setPersistentAgentAudienceEnabled, + usePersistentAgentAudience, +} from "@/features/messages/lib/persistentAgentAudience"; export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = usePreventSleepContext(); + const persistentAudience = usePersistentAgentAudience(null); return (
@@ -12,6 +17,30 @@ export function PreventSleepSettingsCard() { data-testid="agents-preferences-card" title="Preferences" > + +
+ +

+ Keep agents you address selected for future messages in the same + channel or thread. Remove them from the composer at any time. +

+
+ +
+