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,