Fix task channel smoke regressions

This commit is contained in:
klopez4212
2026-06-30 13:55:21 +01:00
parent 445da2512b
commit 5dba68bcd2
8 changed files with 308 additions and 65 deletions
@@ -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}
@@ -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,
@@ -1,12 +1,27 @@
export const WAVE_MESSAGE_MARKER = "<!-- buzz:wave:v1 -->";
const WAVE_TARGET_PREFIX = "<!-- buzz:wave-target:";
const WAVE_TARGET_SUFFIX = "-->";
const WAVE_TARGET_AGENT_LINE = "<!-- buzz:wave-target-agent:1 -->";
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,
};
}
@@ -383,10 +383,13 @@ export const MessageRow = React.memo(
if (waveMessage) {
return (
<WaveMessageAttachment
agentPubkeys={agentPubkeys}
channelId={channelId}
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
targetIsAgent={waveMessage.targetIsAgent}
targetPubkey={waveMessage.targetPubkey}
/>
);
}
@@ -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 ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex translate-y-3 justify-center px-4",
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { channelsQueryKey } from "@/features/channels/hooks";
import { useHuddle } from "@/features/huddle";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
Attachment,
AttachmentAction,
@@ -17,6 +18,9 @@ import {
type WaveMessageAttachmentProps = {
channelId?: string | null;
fallbackText: string;
targetPubkey?: string;
targetIsAgent?: boolean;
agentPubkeys?: ReadonlySet<string>;
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<string>();
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<HTMLButtonElement>) => {
@@ -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,
@@ -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<HTMLElement>("[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, { kind: "message" }>,
): AnchorState {
let anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(anchor.messageId)}"]`,
);
let activeAnchor: Extract<AnchorState, { kind: "message" }> = anchor;
if (!anchorEl) {
const fallbackId = findNearestNewerMessageId(
container,
messages,
anchor.messageId,
);
if (fallbackId) {
anchorEl = container.querySelector<HTMLElement>(
`[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<AnchorState>({ kind: "at-bottom" });
const messagesRef = React.useRef<TimelineMessage[]>(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<string | undefined>(undefined);
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const pendingPrependAnchorRef = React.useRef<AnchorState | null>(null);
const handledTargetIdRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(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<number | null>(null);
const prependRestoreRafIdRef = React.useRef<number | null>(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<HTMLElement>(
`[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);
@@ -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,