diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index b4963f4bf..143a9f79b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -812,6 +812,8 @@ export const ChannelPane = React.memo(function ChannelPane({ followThreadById={followThreadById} hasComposerOverlay={hasMainComposerOverlay} hasOlderMessages={hasOlderMessages} + huddleMemberPubkeys={huddleMemberPubkeys} + huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFetchingOlder={isFetchingOlder} isFollowingThreadById={isFollowingThreadById} isMessageUnreadById={isMessageUnreadById} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 623bd9408..de2bf8aef 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -364,14 +364,19 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]); + const agentLookupPending = + channelMembersQuery.isPending || + channelMembersQuery.isFetching || + managedAgentsQuery.isPending || + managedAgentsQuery.isFetching || + relayAgentsQuery.isPending || + relayAgentsQuery.isFetching || + (messageProfilePubkeys.length > 0 && + (messageProfilesQuery.isPending || + messageProfilesQuery.isFetching || + messageProfilesQuery.isPlaceholderData)); const agentPubkeysPending = - activeChannel?.channelType === "dm" && - (channelMembersQuery.isPending || - managedAgentsQuery.isPending || - relayAgentsQuery.isPending || - (messageProfilePubkeys.length > 0 && - (messageProfilesQuery.isPending || - messageProfilesQuery.isPlaceholderData))); + activeChannel?.channelType === "dm" && agentLookupPending; const { agentSessionCandidates, botTypingEntries, diff --git a/desktop/src/features/messages/lib/waveMessage.ts b/desktop/src/features/messages/lib/waveMessage.ts index 7ef320dd3..fe3d9a1cf 100644 --- a/desktop/src/features/messages/lib/waveMessage.ts +++ b/desktop/src/features/messages/lib/waveMessage.ts @@ -1,12 +1,27 @@ export const WAVE_MESSAGE_MARKER = ""; +const WAVE_TARGET_PREFIX = ""; +const WAVE_TARGET_AGENT_LINE = ""; export type WaveMessageContent = { fallbackText: string; + targetPubkey?: string; + targetIsAgent?: boolean; }; -export function buildWaveMessageContent(senderName: string): string { +export function buildWaveMessageContent( + senderName: string, + targetPubkey?: string, + options?: { targetIsAgent?: boolean }, +): string { const trimmedName = senderName.trim() || "Someone"; - return `${WAVE_MESSAGE_MARKER}\n${trimmedName} waved at you.`; + const targetLine = targetPubkey + ? `\n${WAVE_TARGET_PREFIX}${targetPubkey.toLowerCase()}${WAVE_TARGET_SUFFIX}` + : ""; + const targetAgentLine = options?.targetIsAgent + ? `\n${WAVE_TARGET_AGENT_LINE}` + : ""; + return `${WAVE_MESSAGE_MARKER}${targetLine}${targetAgentLine}\n${trimmedName} waved at you.`; } export function parseWaveMessageContent( @@ -18,9 +33,27 @@ export function parseWaveMessageContent( return null; } - const fallbackText = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim(); + let body = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim(); + let targetPubkey: string | undefined; + let targetIsAgent = false; + if (body.startsWith(WAVE_TARGET_PREFIX)) { + const suffixIndex = body.indexOf(WAVE_TARGET_SUFFIX); + if (suffixIndex >= 0) { + targetPubkey = body + .slice(WAVE_TARGET_PREFIX.length, suffixIndex) + .trim() + .toLowerCase(); + body = body.slice(suffixIndex + WAVE_TARGET_SUFFIX.length).trim(); + } + } + if (body.startsWith(WAVE_TARGET_AGENT_LINE)) { + targetIsAgent = true; + body = body.slice(WAVE_TARGET_AGENT_LINE.length).trim(); + } return { - fallbackText: fallbackText || "Someone waved at you.", + fallbackText: body || "Someone waved at you.", + targetPubkey, + targetIsAgent, }; } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 8c3370bc8..7ba31d80f 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -383,10 +383,13 @@ export const MessageRow = React.memo( if (waveMessage) { return ( ); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index f1f7111c4..5a2901693 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -249,6 +249,9 @@ const MessageTimelineBase = React.forwardRef< liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + const isHistoryPrependPending = + isFetchingOlder || + isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages); const { highlightedMessageId, @@ -262,6 +265,7 @@ const MessageTimelineBase = React.forwardRef< channelId, contentRef, isLoading: showTimelineSkeleton, + isHistoryPrependPending, messages: deferredMessages, onTargetReached, resetKey: scrollIdentityKey, @@ -273,7 +277,7 @@ const MessageTimelineBase = React.forwardRef< hasChannelIntro: channelIntro !== null && directMessageIntro === null, hasDirectMessageIntro: directMessageIntro !== null, hasReachedChannelStart: - !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && + !isHistoryPrependPending && (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), isSkeletonVisible: showTimelineSkeleton, }); @@ -411,8 +415,7 @@ const MessageTimelineBase = React.forwardRef< ) : null} {/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame later off the deferred snapshot — keep the spinner up until then. */} - {isFetchingOlder || - isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? ( + {isHistoryPrependPending ? (
; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; }; @@ -24,13 +28,41 @@ type WaveMessageAttachmentProps = { export function WaveMessageAttachment({ channelId, fallbackText, + targetPubkey, + targetIsAgent = false, + agentPubkeys, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); + const normalizedTargetPubkey = targetPubkey + ? normalizePubkey(targetPubkey) + : null; + const targetAgentPubkey = React.useMemo(() => { + if (!targetPubkey || !normalizedTargetPubkey || !agentPubkeys) return null; + return agentPubkeys.has(normalizedTargetPubkey) ? targetPubkey : null; + }, [agentPubkeys, normalizedTargetPubkey, targetPubkey]); + const resolvedHuddleMemberPubkeys = React.useMemo(() => { + if (!targetAgentPubkey) return huddleMemberPubkeys; + const seen = new Set(); + return [...huddleMemberPubkeys, targetAgentPubkey].filter((pubkey) => { + const normalizedPubkey = normalizePubkey(pubkey); + if (seen.has(normalizedPubkey)) return false; + seen.add(normalizedPubkey); + return true; + }); + }, [huddleMemberPubkeys, targetAgentPubkey]); + const targetAgentLookupPending = + targetIsAgent && + normalizedTargetPubkey !== null && + (huddleMemberPubkeysPending || + (targetAgentPubkey === null && + !resolvedHuddleMemberPubkeys.some( + (pubkey) => normalizePubkey(pubkey) === normalizedTargetPubkey, + ))); const startHuddleDisabled = - !channelId || isStarting || huddleMemberPubkeysPending; + !channelId || isStarting || targetAgentLookupPending; const handleStartHuddle = React.useCallback( async (event: React.MouseEvent) => { @@ -42,7 +74,7 @@ export function WaveMessageAttachment({ } try { - await startHuddle(channelId, [...huddleMemberPubkeys]); + await startHuddle(channelId, [...resolvedHuddleMemberPubkeys]); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); } catch (error) { toast.error( @@ -52,7 +84,7 @@ export function WaveMessageAttachment({ }, [ channelId, - huddleMemberPubkeys, + resolvedHuddleMemberPubkeys, queryClient, startHuddle, startHuddleDisabled, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index e65bead5b..0469d4247 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -47,6 +47,10 @@ type UseAnchoredScrollOptions = { /** Source of truth for the rendered list. Used to detect new-at-bottom * arrivals and to seed/refresh the anchor pre-render. */ messages: TimelineMessage[]; + /** True while older history is being fetched or the rendered snapshot is + * waiting to catch up to a prepend. During this window, native scroll + * anchoring can fire scroll events before React's post-commit restore runs. */ + isHistoryPrependPending?: boolean; /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; @@ -103,19 +107,20 @@ function isAtTrueBottom( /** * Pick an anchor for the current scroll position. * - * Top-crossing walk: chronological children, top-down. The first - * `data-message-id` row whose bottom edge has crossed below the container - * top is the anchor — that's the row the reader's eye is on when they've - * scrolled up through history. `topOffset` is the row's top relative to - * the container's top and may be negative when the row straddles the edge. + * Top-visible walk: chronological children, top-down. The first + * `data-message-id` row whose top edge is inside the container is the anchor — + * that's the row the reader's eye is on when they've scrolled up through + * history. If the viewport starts in the middle of a row, we fall back to that + * clipped row. `topOffset` is the row's top relative to the container's top and + * may be negative when the fallback row straddles the edge. * * If no such row exists (e.g. nothing scrolled past the top, list shorter * than the viewport, etc.) the anchor is `at-bottom`. * * Algorithm credit: Sami's [13] in the buzz-bugs scroll-redesign thread, - * supersedes the Matrix-style bottom-up walk in [7]. The top-crossing - * choice is what keeps the row the reader is *reading* fixed under - * in-viewport reflow (image-load, embed expansion). + * supersedes the Matrix-style bottom-up walk in [7]. The top-visible choice is + * what keeps the row the reader is *reading* fixed under in-viewport reflow + * (image-load, embed expansion). */ function computeAnchor(container: HTMLDivElement): AnchorState { if (isAtBottomNow(container)) { @@ -124,23 +129,100 @@ function computeAnchor(container: HTMLDivElement): AnchorState { const containerTop = container.getBoundingClientRect().top; const rows = container.querySelectorAll("[data-message-id]"); + let firstVisibleRow: HTMLElement | null = null; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rect = row.getBoundingClientRect(); if (rect.bottom > containerTop) { - const messageId = row.dataset.messageId; - if (messageId) { - return { + firstVisibleRow ??= row; + if (rect.top >= containerTop) { + const messageId = row.dataset.messageId; + if (messageId) { + return { + kind: "message", + messageId, + topOffset: rect.top - containerTop, + }; + } + } + } + } + + if (firstVisibleRow?.dataset.messageId) { + return { + kind: "message", + messageId: firstVisibleRow.dataset.messageId, + topOffset: firstVisibleRow.getBoundingClientRect().top - containerTop, + }; + } + + return { kind: "at-bottom" }; +} + +function findNearestNewerMessageId( + container: HTMLDivElement, + messages: TimelineMessage[], + anchorId: string, +): string | null { + const anchorIndex = messages.findIndex((message) => message.id === anchorId); + if (anchorIndex < 0) return null; + + for (let index = anchorIndex + 1; index < messages.length; index += 1) { + const candidate = messages[index]; + const el = container.querySelector( + `[data-message-id="${CSS.escape(candidate.id)}"]`, + ); + if (el) return candidate.id; + } + + return null; +} + +function restoreAnchorToMessage( + container: HTMLDivElement, + messages: TimelineMessage[], + anchor: Extract, +): AnchorState { + let anchorEl = container.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + let activeAnchor: Extract = anchor; + + if (!anchorEl) { + const fallbackId = findNearestNewerMessageId( + container, + messages, + anchor.messageId, + ); + if (fallbackId) { + anchorEl = container.querySelector( + `[data-message-id="${CSS.escape(fallbackId)}"]`, + ); + if (anchorEl) { + activeAnchor = { kind: "message", - messageId, - topOffset: rect.top - containerTop, + messageId: fallbackId, + topOffset: anchor.topOffset, }; } } } - return { kind: "at-bottom" }; + if (!anchorEl) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + return { kind: "at-bottom" }; + } + + const currentTopOffset = + anchorEl.getBoundingClientRect().top - + container.getBoundingClientRect().top; + const drift = currentTopOffset - activeAnchor.topOffset; + if (Math.abs(drift) > 0.5) { + container.scrollBy(0, drift); + } + + return activeAnchor; } export function useAnchoredScroll({ @@ -150,6 +232,7 @@ export function useAnchoredScroll({ resetKey = channelId ?? null, isLoading, messages, + isHistoryPrependPending = false, targetMessageId = null, onTargetReached, @@ -158,6 +241,7 @@ export function useAnchoredScroll({ // both on scroll (commit-time read) and in the layout effect (post-render // restoration). useState would force re-renders we don't want. const anchorRef = React.useRef({ kind: "at-bottom" }); + const messagesRef = React.useRef(messages); const [isAtBottom, setIsAtBottom] = React.useState(true); const [newMessageCount, setNewMessageCount] = React.useState(0); const [highlightedMessageId, setHighlightedMessageId] = React.useState< @@ -168,11 +252,13 @@ export function useAnchoredScroll({ const prevLastMessageIdRef = React.useRef(undefined); const prevFirstMessageIdRef = React.useRef(undefined); const prevMessageCountRef = React.useRef(0); + const pendingPrependAnchorRef = React.useRef(null); const handledTargetIdRef = React.useRef(null); const highlightTimeoutRef = React.useRef(null); // Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled // on channel switch (the channelId reset effect clears it). const mountPinRafIdRef = React.useRef(null); + const prependRestoreRafIdRef = React.useRef(null); // One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before // it sends a message (see ChannelPane). When the user's own message then // appends, we snap to bottom even if they had scrolled up to read history. @@ -183,13 +269,27 @@ export function useAnchoredScroll({ // ignores transient gaps and keeps chasing the floor. A `ref`, not state — the // guard runs on a native scroll event, outside React's render cycle. const settlingRef = React.useRef(false); + const anchorUpdateLockedRef = React.useRef(false); // Reset everything when the route's scroll identity changes — the layout // effect that runs immediately after this reset is responsible for either // jumping to bottom or to the target message for the new view. // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is intentionally the sole trigger — it includes channel identity plus route-specific layout state. React.useLayoutEffect(() => { - void scrollScopeKey; + messagesRef.current = messages; + }, [messages]); + + React.useLayoutEffect(() => { + if (isHistoryPrependPending && !anchorUpdateLockedRef.current) { + const container = scrollContainerRef.current; + const anchor = container ? computeAnchor(container) : anchorRef.current; + pendingPrependAnchorRef.current = anchor; + anchorRef.current = anchor; + anchorUpdateLockedRef.current = true; + } + }, [isHistoryPrependPending, scrollContainerRef]); + + React.useLayoutEffect(() => { anchorRef.current = { kind: "at-bottom" }; setIsAtBottom(true); setNewMessageCount(0); @@ -198,9 +298,11 @@ export function useAnchoredScroll({ prevLastMessageIdRef.current = undefined; prevFirstMessageIdRef.current = undefined; prevMessageCountRef.current = 0; + pendingPrependAnchorRef.current = null; handledTargetIdRef.current = null; forceBottomOnNextAppendRef.current = false; settlingRef.current = false; + anchorUpdateLockedRef.current = false; if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); highlightTimeoutRef.current = null; @@ -209,6 +311,10 @@ export function useAnchoredScroll({ cancelAnimationFrame(mountPinRafIdRef.current); mountPinRafIdRef.current = null; } + if (prependRestoreRafIdRef.current !== null) { + cancelAnimationFrame(prependRestoreRafIdRef.current); + prependRestoreRafIdRef.current = null; + } }, [resetKey]); const scrollToBottomImperative = React.useCallback( @@ -316,10 +422,14 @@ export function useAnchoredScroll({ return; } } - anchorRef.current = computeAnchor(container); - const atBottom = anchorRef.current.kind === "at-bottom"; + const nextAnchor = computeAnchor(container); + const atBottom = nextAnchor.kind === "at-bottom"; + if (!anchorUpdateLockedRef.current || atBottom) { + anchorRef.current = nextAnchor; + } setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom)); if (atBottom) { + anchorUpdateLockedRef.current = false; setNewMessageCount(0); } }, [scrollContainerRef]); @@ -410,31 +520,61 @@ export function useAnchoredScroll({ // Stick to bottom across the append. container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); if (newLatestArrived) setNewMessageCount(0); - } else if (messagesArrived > 0) { - // Anchored mid-history. An older-history prepend grows the content above - // the reading row; the browser's native scroll anchoring does NOT correct - // this at the top edge (no anchor node above the viewport when scrollTop - // is ~0), so re-pin the anchored row to its saved offset by id. This is - // the single scroll writer for the prepend — the load-older observer only - // triggers the fetch. We run it in this post-commit layout effect (not the - // observer's promise callback) because the prepended rows commit on a - // deferred snapshot a few frames later, so the row's true position is only - // known here. - const row = container.querySelector( - `[data-message-id="${CSS.escape(anchor.messageId)}"]`, - ); - if (row) { - const currentTopOffset = - row.getBoundingClientRect().top - - container.getBoundingClientRect().top; - const drift = currentTopOffset - anchor.topOffset; - if (Math.abs(drift) > 0.5) { - container.scrollBy(0, drift); - } + if (!isHistoryPrependPending) { + anchorUpdateLockedRef.current = false; } - if (!isPrepend) { + } else if (messagesArrived > 0) { + // Anchored mid-history while rows arrive. Preserve the reading row by id. + // During older-history prepends, scroll events from native anchoring are + // ignored until this restore runs so they don't replace the saved anchor. + if (isPrepend && pendingPrependAnchorRef.current?.kind === "message") { + const restorePrependAnchor = () => + restoreAnchorToMessage( + container, + messages, + pendingPrependAnchorRef.current as Extract< + AnchorState, + { kind: "message" } + >, + ); + const nextAnchor = restorePrependAnchor(); + anchorRef.current = nextAnchor; + if (prependRestoreRafIdRef.current !== null) { + cancelAnimationFrame(prependRestoreRafIdRef.current); + } + prependRestoreRafIdRef.current = requestAnimationFrame(() => { + prependRestoreRafIdRef.current = null; + if (!pendingPrependAnchorRef.current) return; + anchorRef.current = restorePrependAnchor(); + if (!isHistoryPrependPending) { + anchorUpdateLockedRef.current = false; + pendingPrependAnchorRef.current = null; + } + }); + if (nextAnchor.kind === "at-bottom") { + setIsAtBottom(true); + setNewMessageCount(0); + prevLastMessageIdRef.current = lastMessage?.id; + prevFirstMessageIdRef.current = firstMessage?.id; + prevMessageCountRef.current = messages.length; + return; + } + } else { + const nextAnchor = restoreAnchorToMessage(container, messages, anchor); + anchorRef.current = nextAnchor; + if (nextAnchor.kind === "at-bottom") { + setIsAtBottom(true); + setNewMessageCount(0); + prevLastMessageIdRef.current = lastMessage?.id; + prevFirstMessageIdRef.current = firstMessage?.id; + prevMessageCountRef.current = messages.length; + return; + } setNewMessageCount((current) => current + messagesArrived); } + } else if (!isHistoryPrependPending) { + anchorUpdateLockedRef.current = false; + pendingPrependAnchorRef.current = null; } prevLastMessageIdRef.current = lastMessage?.id; @@ -442,6 +582,7 @@ export function useAnchoredScroll({ prevMessageCountRef.current = messages.length; }, [ isLoading, + isHistoryPrependPending, messages, onTargetReached, scrollContainerRef, @@ -451,12 +592,9 @@ export function useAnchoredScroll({ ]); // --------------------------------------------------------------------------- - // Content resize: while stuck to the bottom, an in-viewport reflow (image - // decode, embed expand, late font load) that React isn't driving grows - // `scrollHeight` without a `messages` change, so the layout effect doesn't - // fire — re-pin to the new floor here to stay glued. When anchored - // mid-history, native scroll anchoring (overflow-anchor) holds the reading - // row across the reflow, so there's nothing to do. + // Content resize: late layout changes (image decode, embeds, font load) + // happen outside React's message commits, so re-apply the same anchor restore + // used after prepends. This keeps the row under the reader's eye fixed. // --------------------------------------------------------------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is a deliberate re-subscription trigger — the effect body reads only the stable refs, but on route identity changes the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous route's detached node and re-observe the live one. React.useEffect(() => { @@ -466,8 +604,20 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "at-bottom") { + const anchor = anchorRef.current; + if (anchor.kind === "at-bottom") { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + return; + } + const nextAnchor = restoreAnchorToMessage( + container, + messagesRef.current, + anchor, + ); + anchorRef.current = nextAnchor; + if (nextAnchor.kind === "at-bottom") { + setIsAtBottom(true); + setNewMessageCount(0); } }); observer.observe(content); diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index be6b1f6c3..bad2b0f71 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -10,7 +10,11 @@ import { useChannelsQuery, useOpenDmMutation, } from "@/features/channels/hooks"; -import { useProfileQuery, useUserProfileQuery } from "@/features/profile/hooks"; +import { + useProfileQuery, + useUserProfileQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { useRelayAgentsQuery, @@ -188,6 +192,9 @@ export function UserProfilePopover({ enabled: open, }); const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); + const usersBatchQuery = useUsersBatchQuery(open ? [pubkey] : [], { + enabled: open, + }); const { onOpenAgentSession } = useAgentSession(); const { openProfilePanel } = useProfilePanel(); @@ -202,6 +209,11 @@ export function UserProfilePopover({ role !== "bot" && (relayAgentsQuery.isPending || managedAgentsQuery.isPending); const profile = profileQuery.data; + const isAgentByProfile = Boolean( + usersBatchQuery.data?.profiles[normalizePubkey(pubkey)]?.isAgent, + ); + const isAgentTarget = + isBotProfile || isAgentByProfile || isAgentClassificationPending; const displayName = profile?.displayName ?? truncatePubkey(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone @@ -374,7 +386,9 @@ export function UserProfilePopover({ selfProfileQuery.data?.displayName?.trim() || identity.displayName.trim() || truncatePubkey(identity.pubkey); - const content = buildWaveMessageContent(senderName); + const content = buildWaveMessageContent(senderName, pubkey, { + targetIsAgent: isAgentTarget, + }); const queryKey = channelMessagesKey(dm.id); await queryClient.cancelQueries({ queryKey }); @@ -439,6 +453,7 @@ export function UserProfilePopover({ currentPubkey, goChannel, identityQuery.data, + isAgentTarget, openDmMutation, pendingAction, pubkey,