From c78e2c1c78b38d64f3f71845e32d026fe30374a2 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 10:18:47 +0100
Subject: [PATCH 01/15] Polish profile hover avatars

---
 .../channels/ui/ChannelHeaderStatusBadge.tsx  |  22 +-
 .../channels/ui/ChannelScreenHeader.tsx       |  35 +-
 desktop/src/features/chat/ui/ChatHeader.tsx   |   7 +-
 desktop/src/features/home/lib/inbox.ts        |   1 +
 desktop/src/features/home/ui/HomeView.tsx     |   4 +
 .../src/features/home/ui/InboxDetailPane.tsx  |   1 +
 .../src/features/home/ui/InboxListPane.tsx    |  35 +-
 .../src/features/home/ui/InboxMessageRow.tsx  |  32 +-
 .../profile/ui/MaskedAvatarBadgeFrame.tsx     | 444 ++++++++++++++++++
 .../profile/ui/ProfileAvatarWithStatus.tsx    | 109 +++++
 .../profile/ui/UserProfilePopover.tsx         | 319 ++++++++++---
 .../features/sidebar/ui/SidebarSection.tsx    |  37 +-
 12 files changed, 912 insertions(+), 134 deletions(-)
 create mode 100644 desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx
 create mode 100644 desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx

diff --git a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
index a1d7d7551d..14942db046 100644
--- a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
+++ b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
@@ -1,38 +1,18 @@
 import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
 import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
-import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
-import type { Channel, PresenceStatus } from "@/shared/api/types";
 
 type ChannelHeaderStatusBadgeProps = {
-  channelType?: Channel["channelType"];
   ephemeralDisplay: EphemeralChannelDisplay | null;
-  presenceStatus: PresenceStatus | null;
 };
 
 export function ChannelHeaderStatusBadge({
-  channelType,
   ephemeralDisplay,
-  presenceStatus,
 }: ChannelHeaderStatusBadgeProps) {
-  const ephemeralBadge = ephemeralDisplay ? (
+  return ephemeralDisplay ? (
     <EphemeralChannelBadge
       display={ephemeralDisplay}
       testId="chat-ephemeral-badge"
       variant="header"
     />
   ) : null;
-
-  if (channelType === "dm" && presenceStatus) {
-    return (
-      <>
-        <PresenceBadge
-          data-testid="chat-presence-badge"
-          status={presenceStatus}
-        />
-        {ephemeralBadge}
-      </>
-    );
-  }
-
-  return ephemeralBadge;
 }
diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
index 311d22449e..b971d584b9 100644
--- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
@@ -8,11 +8,21 @@ import { getChannelDescription } from "@/features/channels/lib/channelDescriptio
 import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
 import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
 import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+  DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+  ProfileAvatarWithStatus,
+  scaleProfileAvatarStatusGeometry,
+} from "@/features/profile/ui/ProfileAvatarWithStatus";
 import { Button } from "@/shared/ui/button";
 import type { Channel, PresenceStatus } from "@/shared/api/types";
 import { UserAvatar } from "@/shared/ui/UserAvatar";
 
+const DM_HEADER_AVATAR_SIZE = 32;
+const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
+  DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+  DM_HEADER_AVATAR_SIZE,
+);
+
 type ChannelScreenHeaderProps = {
   activeChannel: Channel | null;
   activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
@@ -102,11 +112,16 @@ export function ChannelScreenHeader({
               participants={activeDmHeaderParticipants}
             />
           ) : (
-            <ProfileAvatar
+            <ProfileAvatarWithStatus
+              avatarClassName="text-xs"
               avatarUrl={activeDmAvatarUrl}
-              className="h-6 w-6 rounded-full text-2xs"
-              iconClassName="h-3.5 w-3.5"
+              className="mr-1.5 h-8 w-8"
+              geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY}
+              iconClassName="h-4 w-4"
               label={activeChannelTitle}
+              size={DM_HEADER_AVATAR_SIZE}
+              status={activeDmPresenceStatus ?? "offline"}
+              statusTestId="chat-presence-badge"
               testId="chat-header-dm-avatar"
             />
           )
@@ -114,9 +129,7 @@ export function ChannelScreenHeader({
       }
       statusBadge={
         <ChannelHeaderStatusBadge
-          channelType={activeChannel?.channelType}
           ephemeralDisplay={activeChannelEphemeralDisplay}
-          presenceStatus={isGroupDm ? null : activeDmPresenceStatus}
         />
       }
       title={activeChannelTitle}
@@ -137,7 +150,7 @@ function DmHeaderParticipantStack({
   return (
     <div
       aria-hidden="true"
-      className="mr-1 flex shrink-0 items-center"
+      className="mr-1.5 flex shrink-0 items-center"
       data-testid="chat-header-dm-avatar-stack"
     >
       {visibleParticipants.map((participant, index) => (
@@ -148,15 +161,15 @@ function DmHeaderParticipantStack({
           style={{
             zIndex: index + 1,
             ...(index < stackItemCount - 1 && {
-              mask: "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
+              mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
               WebkitMask:
-                "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
+                "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
             }),
           }}
         >
           <UserAvatar
             avatarUrl={participant.avatarUrl}
-            className="h-7 w-7 text-2xs"
+            className="h-8 w-8 text-xs"
             displayName={participant.displayName}
             size="sm"
           />
@@ -168,7 +181,7 @@ function DmHeaderParticipantStack({
           data-testid="chat-header-dm-avatar-stack-more"
           style={{ zIndex: stackItemCount }}
         >
-          <span className="flex h-7 w-7 items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
+          <span className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
             <span className="text-2xs leading-none">+{hiddenCount}</span>
           </span>
         </div>
diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx
index 39d8b46dc7..814030ef47 100644
--- a/desktop/src/features/chat/ui/ChatHeader.tsx
+++ b/desktop/src/features/chat/ui/ChatHeader.tsx
@@ -120,7 +120,7 @@ export function ChatHeader({
       <div className="flex h-9 min-w-0 items-center gap-2.5">
         <div className="min-w-0 flex-1">
           <div className="group/title flex min-w-0 items-center gap-[4px] overflow-hidden">
-            <div className="shrink-0">
+            <div className="flex shrink-0 items-center">
               {leadingContent ?? (
                 <ChannelIcon
                   channelType={channelType}
@@ -130,7 +130,10 @@ export function ChatHeader({
               )}
             </div>
             <h1
-              className="min-w-0 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
+              className={cn(
+                "min-w-0 truncate text-base font-semibold leading-6 tracking-tight",
+                channelType !== "dm" && "translate-y-px",
+              )}
               data-testid="chat-title"
               title={trimmedDescription || undefined}
             >
diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts
index 6d1f28bd1b..1afae6f642 100644
--- a/desktop/src/features/home/lib/inbox.ts
+++ b/desktop/src/features/home/lib/inbox.ts
@@ -50,6 +50,7 @@ export type InboxTypeLabel = {
 
 export type InboxReply = {
   authorLabel: string;
+  authorPubkey: string;
   avatarUrl: string | null;
   content: string;
   depth?: number;
diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx
index cf2974c856..6dabe35e36 100644
--- a/desktop/src/features/home/ui/HomeView.tsx
+++ b/desktop/src/features/home/ui/HomeView.tsx
@@ -279,9 +279,12 @@ export function HomeView({
 
     return timelineMessages.map((message) => {
       const event = eventById.get(message.id);
+      const authorPubkey =
+        message.pubkey ?? event?.pubkey ?? selectedItem.item.pubkey;
       return {
         id: message.id,
         authorLabel: message.author,
+        authorPubkey,
         avatarUrl: message.avatarUrl ?? null,
         content: message.body,
         depth: event ? getContextMessageDepth(event, eventById) : message.depth,
@@ -604,6 +607,7 @@ export function HomeView({
                         pubkey: authorPubkey,
                       })
                     : "You",
+                  authorPubkey,
                   avatarUrl:
                     currentPubkey && feedProfiles
                       ? (feedProfiles[currentPubkey.trim().toLowerCase()]
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx
index 33848e0f7c..971a2db285 100644
--- a/desktop/src/features/home/ui/InboxDetailPane.tsx
+++ b/desktop/src/features/home/ui/InboxDetailPane.tsx
@@ -185,6 +185,7 @@ export function InboxDetailPane({
       : [
           {
             authorLabel: item.senderLabel,
+            authorPubkey: item.item.pubkey,
             avatarUrl: item.avatarUrl,
             content: item.preview,
             depth: 0,
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index ed8130edbf..d2f4fcdbc3 100644
--- a/desktop/src/features/home/ui/InboxListPane.tsx
+++ b/desktop/src/features/home/ui/InboxListPane.tsx
@@ -13,6 +13,7 @@ import {
   type InboxItem,
   type InboxTypeLabel,
 } from "@/features/home/lib/inbox";
+import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
 import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel";
 import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
 import { cn } from "@/shared/lib/cn";
@@ -186,19 +187,35 @@ export function InboxListPane({
           />
           <div className="relative flex min-w-0 items-start gap-2.5">
             <div className="relative shrink-0">
-              <UserAvatar
-                avatarUrl={item.avatarUrl}
-                className="h-9 w-9"
-                displayName={item.senderLabel}
-                size="md"
-              />
+              <UserProfilePopover
+                enableProfilePanel={false}
+                pubkey={item.item.pubkey}
+                triggerElement="span"
+              >
+                <span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
+                  <UserAvatar
+                    avatarUrl={item.avatarUrl}
+                    className="h-9 w-9"
+                    displayName={item.senderLabel}
+                    size="md"
+                  />
+                </span>
+              </UserProfilePopover>
             </div>
 
             <div className="min-w-0 flex-1">
               <div className="flex min-w-0 items-start gap-2">
-                <p className="min-w-0 flex-1 truncate text-sm font-semibold leading-4 text-foreground">
-                  {item.senderLabel}
-                </p>
+                <span className="min-w-0 flex-1">
+                  <UserProfilePopover
+                    enableProfilePanel={false}
+                    pubkey={item.item.pubkey}
+                    triggerElement="span"
+                  >
+                    <span className="block max-w-full truncate rounded text-sm font-semibold leading-4 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
+                      {item.senderLabel}
+                    </span>
+                  </UserProfilePopover>
+                </span>
                 <span
                   className={cn(
                     "flex shrink-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx
index 23904b23b3..01b03c4015 100644
--- a/desktop/src/features/home/ui/InboxMessageRow.tsx
+++ b/desktop/src/features/home/ui/InboxMessageRow.tsx
@@ -6,6 +6,7 @@ import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
 import { MessageReactions } from "@/features/messages/ui/MessageReactions";
 import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
 import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
+import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
 import { cn } from "@/shared/lib/cn";
 import { Markdown } from "@/shared/ui/markdown";
 import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -22,6 +23,7 @@ function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
     body: message.content,
     createdAt: 0,
     depth: message.depth,
+    pubkey: message.authorPubkey,
     reactions: message.reactions ?? [],
     tags: message.tags,
     time: message.fullTimestampLabel,
@@ -114,19 +116,31 @@ export function InboxMessageRow({
         ) : null}
 
         <div className="relative shrink-0">
-          <UserAvatar
-            avatarUrl={message.avatarUrl}
-            className="h-9 w-9 shrink-0"
-            displayName={message.authorLabel}
-            size="md"
-          />
+          <UserProfilePopover
+            pubkey={message.authorPubkey}
+            triggerElement="span"
+          >
+            <span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
+              <UserAvatar
+                avatarUrl={message.avatarUrl}
+                className="h-9 w-9 shrink-0"
+                displayName={message.authorLabel}
+                size="md"
+              />
+            </span>
+          </UserProfilePopover>
         </div>
 
         <div className="min-w-0 flex-1">
           <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
-            <p className="truncate text-sm font-semibold text-foreground">
-              {message.authorLabel}
-            </p>
+            <UserProfilePopover
+              pubkey={message.authorPubkey}
+              triggerElement="span"
+            >
+              <span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
+                {message.authorLabel}
+              </span>
+            </UserProfilePopover>
             <p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
               {message.fullTimestampLabel}
             </p>
diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx
new file mode 100644
index 0000000000..426e226605
--- /dev/null
+++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx
@@ -0,0 +1,444 @@
+import type * as React from "react";
+
+import { motion } from "motion/react";
+
+import { cn } from "@/shared/lib/cn";
+
+export type AvatarBadgeCircle = {
+  cx: number;
+  cy: number;
+  r: number;
+};
+
+export type AvatarBadgeBox = {
+  bottom: number;
+  height: number;
+  right: number;
+  width: number;
+};
+
+type Point = {
+  x: number;
+  y: number;
+};
+
+type RoundedAvatarMaskGeometry = {
+  avatar: AvatarBadgeCircle;
+  avatarLower: Point;
+  avatarUpper: Point;
+  cutout: AvatarBadgeCircle;
+  cutoutLower: Point;
+  cutoutUpper: Point;
+  lowerHandleLength: number;
+  upperHandleLength: number;
+};
+
+type BadgeMotionTarget = {
+  height: string;
+  left: string;
+  top: string;
+  width: string;
+};
+
+type MaskedAvatarBadgeFrameProps = {
+  badge?: React.ReactNode;
+  badgeBox?: AvatarBadgeBox;
+  children: React.ReactNode;
+  className?: string;
+  clipTestId?: string;
+  curve?: AvatarBadgeCurve;
+  cutout?: AvatarBadgeCircle;
+  maskTransition?: React.ComponentProps<typeof motion.path>["transition"];
+  size: number;
+};
+
+export type AvatarBadgeCurve = Partial<{
+  avatarRoundingAngle: number;
+  cutoutRoundingLength: number;
+  cutoutRoundingMinAngle: number;
+  cutoutRoundingMaxAngle: number;
+  handleDistanceRatio: number;
+  handleLengthRatio: number;
+}>;
+
+const DEFAULT_AVATAR_BADGE_CURVE = {
+  avatarRoundingAngle: 0.075,
+  cutoutRoundingLength: 5.5,
+  cutoutRoundingMinAngle: 0.22,
+  cutoutRoundingMaxAngle: 0.38,
+  handleDistanceRatio: 0.42,
+  handleLengthRatio: 0.14,
+} as const;
+
+export const STATUS_DOT_MASK_CURVE = {
+  avatarRoundingAngle: 0.11,
+  cutoutRoundingLength: 6.5,
+  cutoutRoundingMinAngle: 0.28,
+  cutoutRoundingMaxAngle: 0.44,
+  handleDistanceRatio: 0.5,
+  handleLengthRatio: 0.2,
+} satisfies AvatarBadgeCurve;
+
+function getCircleIntersections(
+  avatar: AvatarBadgeCircle,
+  cutout: AvatarBadgeCircle,
+): [Point, Point] {
+  const dx = cutout.cx - avatar.cx;
+  const dy = cutout.cy - avatar.cy;
+  const distance = Math.hypot(dx, dy);
+  const distanceToMidpoint =
+    (avatar.r * avatar.r - cutout.r * cutout.r + distance * distance) /
+    (2 * distance);
+  const halfChord = Math.sqrt(
+    Math.max(0, avatar.r * avatar.r - distanceToMidpoint * distanceToMidpoint),
+  );
+  const ux = dx / distance;
+  const uy = dy / distance;
+  const midpoint = {
+    x: avatar.cx + distanceToMidpoint * ux,
+    y: avatar.cy + distanceToMidpoint * uy,
+  };
+  const perpendicular = { x: -uy, y: ux };
+
+  return [
+    {
+      x: midpoint.x + halfChord * perpendicular.x,
+      y: midpoint.y + halfChord * perpendicular.y,
+    },
+    {
+      x: midpoint.x - halfChord * perpendicular.x,
+      y: midpoint.y - halfChord * perpendicular.y,
+    },
+  ];
+}
+
+function getAngle(circle: AvatarBadgeCircle, point: Point) {
+  return Math.atan2(point.y - circle.cy, point.x - circle.cx);
+}
+
+function getPointOnCircle(circle: AvatarBadgeCircle, angle: number): Point {
+  return {
+    x: circle.cx + circle.r * Math.cos(angle),
+    y: circle.cy + circle.r * Math.sin(angle),
+  };
+}
+
+function getTangent(angle: number, direction: 1 | -1): Point {
+  return direction === 1
+    ? { x: -Math.sin(angle), y: Math.cos(angle) }
+    : { x: Math.sin(angle), y: -Math.cos(angle) };
+}
+
+function getControlPoint(
+  point: Point,
+  tangent: Point,
+  distance: number,
+): Point {
+  return {
+    x: point.x + tangent.x * distance,
+    y: point.y + tangent.y * distance,
+  };
+}
+
+function getDistance(a: Point, b: Point) {
+  return Math.hypot(a.x - b.x, a.y - b.y);
+}
+
+function toRem(value: number) {
+  return `${value / 16}rem`;
+}
+
+function toPercent(value: number) {
+  return `${value * 100}%`;
+}
+
+function getBadgeMotionTarget(
+  size: number,
+  badgeBox: AvatarBadgeBox,
+  cutout: AvatarBadgeCircle,
+): BadgeMotionTarget {
+  return {
+    height: toPercent(badgeBox.height / size),
+    left: toPercent(cutout.cx / size),
+    top: toPercent(cutout.cy / size),
+    width: toPercent(badgeBox.width / size),
+  };
+}
+
+function getBadgeStyle(target: BadgeMotionTarget): React.CSSProperties {
+  return {
+    ...target,
+    transform: "translate3d(-50%, -50%, 0)",
+    transformOrigin: "center",
+  };
+}
+
+function getRoundedAvatarMaskGeometry(
+  size: number,
+  cutout: AvatarBadgeCircle,
+  curve?: AvatarBadgeCurve,
+): RoundedAvatarMaskGeometry {
+  const resolvedCurve = { ...DEFAULT_AVATAR_BADGE_CURVE, ...curve };
+  const avatar = { cx: size / 2, cy: size / 2, r: size / 2 };
+  const [firstIntersection, secondIntersection] = getCircleIntersections(
+    avatar,
+    cutout,
+  );
+  const upperIntersection =
+    firstIntersection.y < secondIntersection.y
+      ? firstIntersection
+      : secondIntersection;
+  const lowerIntersection =
+    firstIntersection.y < secondIntersection.y
+      ? secondIntersection
+      : firstIntersection;
+  const cutoutRoundingAngle = Math.min(
+    resolvedCurve.cutoutRoundingMaxAngle,
+    Math.max(
+      resolvedCurve.cutoutRoundingMinAngle,
+      resolvedCurve.cutoutRoundingLength / cutout.r,
+    ),
+  );
+
+  const avatarUpper = getPointOnCircle(
+    avatar,
+    getAngle(avatar, upperIntersection) - resolvedCurve.avatarRoundingAngle,
+  );
+  const avatarLower = getPointOnCircle(
+    avatar,
+    getAngle(avatar, lowerIntersection) + resolvedCurve.avatarRoundingAngle,
+  );
+  const cutoutUpper = getPointOnCircle(
+    cutout,
+    getAngle(cutout, upperIntersection) - cutoutRoundingAngle,
+  );
+  const cutoutLower = getPointOnCircle(
+    cutout,
+    getAngle(cutout, lowerIntersection) + cutoutRoundingAngle,
+  );
+
+  const upperHandleLength = Math.min(
+    cutout.r * resolvedCurve.handleLengthRatio,
+    getDistance(cutoutUpper, avatarUpper) * resolvedCurve.handleDistanceRatio,
+  );
+  const lowerHandleLength = Math.min(
+    cutout.r * resolvedCurve.handleLengthRatio,
+    getDistance(avatarLower, cutoutLower) * resolvedCurve.handleDistanceRatio,
+  );
+  return {
+    avatar,
+    avatarLower,
+    avatarUpper,
+    cutout,
+    cutoutLower,
+    cutoutUpper,
+    lowerHandleLength,
+    upperHandleLength,
+  };
+}
+
+function getCubicPoint(
+  start: Point,
+  firstControl: Point,
+  secondControl: Point,
+  end: Point,
+  progress: number,
+): Point {
+  const remaining = 1 - progress;
+
+  return {
+    x:
+      remaining * remaining * remaining * start.x +
+      3 * remaining * remaining * progress * firstControl.x +
+      3 * remaining * progress * progress * secondControl.x +
+      progress * progress * progress * end.x,
+    y:
+      remaining * remaining * remaining * start.y +
+      3 * remaining * remaining * progress * firstControl.y +
+      3 * remaining * progress * progress * secondControl.y +
+      progress * progress * progress * end.y,
+  };
+}
+
+function sampleCubic(
+  start: Point,
+  firstControl: Point,
+  secondControl: Point,
+  end: Point,
+  segments: number,
+) {
+  return Array.from({ length: segments }, (_, index) =>
+    getCubicPoint(
+      start,
+      firstControl,
+      secondControl,
+      end,
+      (index + 1) / segments,
+    ),
+  );
+}
+
+function getArcSweep(
+  startAngle: number,
+  endAngle: number,
+  direction: 1 | -1,
+  largeArc: boolean,
+) {
+  const fullTurn = Math.PI * 2;
+
+  if (direction === 1) {
+    let sweep = endAngle - startAngle;
+    while (sweep < 0) sweep += fullTurn;
+    if (largeArc && sweep < Math.PI) sweep += fullTurn;
+    if (!largeArc && sweep > Math.PI) sweep -= fullTurn;
+    return sweep;
+  }
+
+  let sweep = startAngle - endAngle;
+  while (sweep < 0) sweep += fullTurn;
+  if (largeArc && sweep < Math.PI) sweep += fullTurn;
+  if (!largeArc && sweep > Math.PI) sweep -= fullTurn;
+  return -sweep;
+}
+
+function sampleArc(
+  circle: AvatarBadgeCircle,
+  startAngle: number,
+  endAngle: number,
+  direction: 1 | -1,
+  largeArc: boolean,
+  segments: number,
+) {
+  const sweep = getArcSweep(startAngle, endAngle, direction, largeArc);
+
+  return Array.from({ length: segments }, (_, index) =>
+    getPointOnCircle(circle, startAngle + sweep * ((index + 1) / segments)),
+  );
+}
+
+function toPolygonPoint(point: Point, size: number) {
+  return `${toPercent(point.x / size)} ${toPercent(point.y / size)}`;
+}
+
+function getRoundedAvatarMaskPolygon(
+  size: number,
+  cutout: AvatarBadgeCircle,
+  curve?: AvatarBadgeCurve,
+) {
+  const {
+    avatar,
+    avatarLower,
+    avatarUpper,
+    cutout: resolvedCutout,
+    cutoutLower,
+    cutoutUpper,
+    lowerHandleLength,
+    upperHandleLength,
+  } = getRoundedAvatarMaskGeometry(size, cutout, curve);
+  const upperCutoutTangent = getTangent(
+    getAngle(resolvedCutout, cutoutUpper),
+    1,
+  );
+  const upperAvatarTangent = getTangent(getAngle(avatar, avatarUpper), -1);
+  const lowerAvatarTangent = getTangent(getAngle(avatar, avatarLower), -1);
+  const lowerCutoutTangent = getTangent(
+    getAngle(resolvedCutout, cutoutLower),
+    1,
+  );
+  const points = [
+    cutoutUpper,
+    ...sampleCubic(
+      cutoutUpper,
+      getControlPoint(cutoutUpper, upperCutoutTangent, upperHandleLength),
+      getControlPoint(avatarUpper, upperAvatarTangent, -upperHandleLength),
+      avatarUpper,
+      12,
+    ),
+    ...sampleArc(
+      avatar,
+      getAngle(avatar, avatarUpper),
+      getAngle(avatar, avatarLower),
+      -1,
+      true,
+      96,
+    ),
+    ...sampleCubic(
+      avatarLower,
+      getControlPoint(avatarLower, lowerAvatarTangent, lowerHandleLength),
+      getControlPoint(cutoutLower, lowerCutoutTangent, -lowerHandleLength),
+      cutoutLower,
+      12,
+    ),
+    ...sampleArc(
+      resolvedCutout,
+      getAngle(resolvedCutout, cutoutLower),
+      getAngle(resolvedCutout, cutoutUpper),
+      1,
+      false,
+      24,
+    ),
+  ];
+
+  return `polygon(${points.map((point) => toPolygonPoint(point, size)).join(", ")})`;
+}
+
+export function MaskedAvatarBadgeFrame({
+  badge,
+  badgeBox,
+  children,
+  className,
+  clipTestId,
+  curve,
+  cutout,
+  maskTransition,
+  size,
+}: MaskedAvatarBadgeFrameProps) {
+  const shouldMask = Boolean(badge && badgeBox && cutout);
+  const maskPolygon = cutout
+    ? getRoundedAvatarMaskPolygon(size, cutout, curve)
+    : undefined;
+  const sizeStyle = { height: toRem(size), width: toRem(size) };
+  const badgeMotionTarget =
+    badgeBox && cutout
+      ? getBadgeMotionTarget(size, badgeBox, cutout)
+      : undefined;
+  const badgeStyle = badgeMotionTarget
+    ? getBadgeStyle(badgeMotionTarget)
+    : undefined;
+
+  if (!shouldMask) {
+    return (
+      <div className={cn("relative shrink-0", className)} style={sizeStyle}>
+        {children}
+      </div>
+    );
+  }
+
+  return (
+    <div className={cn("relative shrink-0", className)} style={sizeStyle}>
+      <motion.div
+        animate={maskTransition ? { clipPath: maskPolygon } : undefined}
+        className="h-full w-full"
+        data-testid={clipTestId}
+        initial={false}
+        style={{
+          WebkitClipPath: maskPolygon,
+          clipPath: maskPolygon,
+        }}
+        transition={maskTransition}
+      >
+        {children}
+      </motion.div>
+
+      <motion.span
+        animate={maskTransition ? badgeMotionTarget : undefined}
+        className="absolute z-20 flex items-center justify-center rounded-full"
+        initial={false}
+        style={badgeStyle}
+        transition={maskTransition}
+      >
+        {badge}
+      </motion.span>
+    </div>
+  );
+}
diff --git a/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
new file mode 100644
index 0000000000..2842f66a9e
--- /dev/null
+++ b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
@@ -0,0 +1,109 @@
+import { getPresenceLabel } from "@/features/presence/lib/presence";
+import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
+import type { PresenceStatus } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import {
+  MaskedAvatarBadgeFrame,
+  STATUS_DOT_MASK_CURVE,
+} from "./MaskedAvatarBadgeFrame";
+import { ProfileAvatar } from "./ProfileAvatar";
+
+export type ProfileAvatarStatusGeometry = {
+  dotSize: number;
+  cutoutSize: number;
+  centerX: number;
+  centerY: number;
+};
+
+type ProfileAvatarWithStatusProps = {
+  avatarClassName?: string;
+  avatarUrl: string | null;
+  className?: string;
+  geometry?: ProfileAvatarStatusGeometry;
+  iconClassName?: string;
+  label: string;
+  size: number;
+  status?: PresenceStatus;
+  statusTestId?: string;
+  testId?: string;
+};
+
+export const DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY = {
+  dotSize: 10,
+  cutoutSize: 16,
+  centerX: 34,
+  centerY: 34,
+} satisfies ProfileAvatarStatusGeometry;
+
+export function scaleProfileAvatarStatusGeometry(
+  geometry: ProfileAvatarStatusGeometry,
+  size: number,
+  baseSize = 40,
+): ProfileAvatarStatusGeometry {
+  const scale = size / baseSize;
+  return {
+    dotSize: geometry.dotSize * scale,
+    cutoutSize: geometry.cutoutSize * scale,
+    centerX: geometry.centerX * scale,
+    centerY: geometry.centerY * scale,
+  };
+}
+
+export function ProfileAvatarWithStatus({
+  avatarClassName,
+  avatarUrl,
+  className,
+  geometry = DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+  iconClassName,
+  label,
+  size,
+  status,
+  statusTestId,
+  testId,
+}: ProfileAvatarWithStatusProps) {
+  const cutout = status
+    ? {
+        cx: geometry.centerX,
+        cy: geometry.centerY,
+        r: geometry.cutoutSize / 2,
+      }
+    : undefined;
+  const badgeBox = status
+    ? {
+        bottom: size - geometry.centerY - geometry.dotSize / 2,
+        height: geometry.dotSize,
+        right: size - geometry.centerX - geometry.dotSize / 2,
+        width: geometry.dotSize,
+      }
+    : undefined;
+
+  return (
+    <MaskedAvatarBadgeFrame
+      badge={
+        status ? (
+          <span
+            aria-label={getPresenceLabel(status)}
+            className="flex h-full w-full items-center justify-center rounded-full"
+            data-testid={statusTestId}
+            role="img"
+          >
+            <PresenceDot className="h-full w-full" status={status} />
+          </span>
+        ) : undefined
+      }
+      badgeBox={badgeBox}
+      className={cn("inline-flex", className)}
+      curve={STATUS_DOT_MASK_CURVE}
+      cutout={cutout}
+      size={size}
+    >
+      <ProfileAvatar
+        avatarUrl={avatarUrl}
+        className={cn("h-full w-full rounded-full", avatarClassName)}
+        iconClassName={iconClassName}
+        label={label}
+        testId={testId}
+      />
+    </MaskedAvatarBadgeFrame>
+  );
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 7562d649cf..780bb56dd8 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -1,6 +1,15 @@
 import * as React from "react";
-import { Activity } from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
+import { Activity, Headphones, MessageSquare } from "lucide-react";
+import { toast } from "sonner";
 
+import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+import { useHuddle } from "@/features/huddle";
+import {
+  channelsQueryKey,
+  useChannelsQuery,
+  useOpenDmMutation,
+} from "@/features/channels/hooks";
 import { useUserProfileQuery } from "@/features/profile/hooks";
 import {
   useRelayAgentsQuery,
@@ -13,22 +22,23 @@ import { truncatePubkey } from "@/features/profile/lib/identity";
 import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
 import { usePresenceQuery } from "@/features/presence/hooks";
 import { useUserStatusQuery } from "@/features/user-status/hooks";
-import { useChannelsQuery } from "@/features/channels/hooks";
 import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
-import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
-import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
-import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
+import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus";
 import { useAgentSession } from "@/shared/context/AgentSessionContext";
 import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
 
 import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
 import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
 import { useNow } from "@/shared/lib/useNow";
+import { Button } from "@/shared/ui/button";
+import { Spinner } from "@/shared/ui/spinner";
 
 type UserProfilePopoverProps = {
   children: React.ReactNode;
   pubkey: string;
   triggerElement?: "div" | "span";
+  /** Set false when the trigger is inside another interactive control. */
+  enableProfilePanel?: boolean;
   /** When set to "bot", a BotIdenticon badge renders next to the display name. */
   role?: string;
   /** Value used to generate the BotIdenticon glyph (typically the author name). */
@@ -57,17 +67,69 @@ function InfoBadge({ children }: { children: React.ReactNode }) {
   );
 }
 
+const TEXT_SWAP_BASE_CLASS =
+  "col-start-1 row-start-1 min-w-0 truncate transition-[opacity,filter] duration-[250ms] ease-in-out motion-reduce:transition-none";
+const TEXT_SWAP_VISIBLE_CLASS = "opacity-100 blur-0";
+const TEXT_SWAP_HIDDEN_CLASS = "opacity-0 blur-0";
+const TEXT_SWAP_HOVER_VISIBLE_CLASS =
+  "group-hover/name:opacity-100 group-hover/name:blur-0";
+const TEXT_SWAP_HOVER_HIDDEN_CLASS =
+  "group-hover/name:opacity-0 group-hover/name:blur-[2px]";
+
+function HoverPubkeyName({
+  displayName,
+  pubkey,
+}: {
+  displayName: string;
+  pubkey: string;
+}) {
+  return (
+    <span className="group/name inline-grid h-5 min-w-0 flex-1 overflow-hidden text-sm font-semibold leading-5">
+      <span
+        className={`${TEXT_SWAP_BASE_CLASS} ${TEXT_SWAP_VISIBLE_CLASS} ${TEXT_SWAP_HOVER_HIDDEN_CLASS}`}
+      >
+        {displayName}
+      </span>
+      <span
+        className={`${TEXT_SWAP_BASE_CLASS} ${TEXT_SWAP_HIDDEN_CLASS} ${TEXT_SWAP_HOVER_VISIBLE_CLASS}`}
+      >
+        {truncatePubkey(pubkey)}
+      </span>
+    </span>
+  );
+}
+
+function StatusLine({ children }: { children: React.ReactNode }) {
+  return (
+    <div
+      className="flex w-full min-w-0 items-center gap-1 py-1 text-xs leading-4 text-muted-foreground"
+      data-testid="user-profile-status"
+    >
+      {children}
+    </div>
+  );
+}
+
 export function UserProfilePopover({
   children,
   pubkey,
   triggerElement = "div",
+  enableProfilePanel = true,
   role,
   botIdenticonValue,
 }: UserProfilePopoverProps) {
   const [open, setOpen] = React.useState(false);
+  const [pendingAction, setPendingAction] = React.useState<
+    "message" | "huddle" | null
+  >(null);
+  const isMountedRef = React.useRef(false);
   const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
     null,
   );
+  const queryClient = useQueryClient();
+  const { goChannel } = useAppNavigation();
+  const openDmMutation = useOpenDmMutation();
+  const { isStarting: isStartingHuddle, startHuddle } = useHuddle();
   const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
   const relayAgentsQuery = useRelayAgentsQuery({
     enabled: open && role === "bot",
@@ -82,11 +144,13 @@ export function UserProfilePopover({
 
   const { onOpenAgentSession } = useAgentSession();
   const { openProfilePanel } = useProfilePanel();
+  const canOpenProfilePanel = enableProfilePanel && Boolean(openProfilePanel);
   const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey);
   const managedAgent = managedAgentsQuery.data?.find(
     (a) => a.pubkey === pubkey,
   );
   const profile = profileQuery.data;
+  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
   // wrongly hides the affordance from them — and gating on bot-ness alone shows
@@ -96,6 +160,10 @@ export function UserProfilePopover({
   const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
   const ownerPubkey = profile?.ownerPubkey ?? null;
   const currentPubkey = useIdentityQuery().data?.pubkey;
+  const isSelf =
+    currentPubkey !== undefined &&
+    currentPubkey.toLowerCase() === pubkey.toLowerCase();
+  const showProfileActions = currentPubkey !== undefined && !isSelf;
   const isCurrentUserOwner =
     currentPubkey !== undefined &&
     ownerPubkey !== null &&
@@ -105,6 +173,10 @@ export function UserProfilePopover({
     role === "bot" && viewerIsOwner && Boolean(onOpenAgentSession);
   const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
   const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
+  const userStatusText = userStatus?.text.trim() ?? "";
+  const hasUserStatus = Boolean(userStatusText || userStatus?.emoji);
+  const profileDescription = profile?.about?.trim() ?? "";
+  const profileSubheader = profileDescription || profile?.nip05Handle?.trim();
   const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
   const channelsQuery = useChannelsQuery();
   const channelIdToName = React.useMemo(() => {
@@ -143,18 +215,92 @@ export function UserProfilePopover({
   const handleTriggerClick = React.useCallback(
     (event: React.MouseEvent) => {
       clearHoverTimer();
-      if (openProfilePanel) {
+      if (canOpenProfilePanel && openProfilePanel) {
         event.preventDefault();
         event.stopPropagation();
         setOpen(false);
         openProfilePanel(pubkey);
       }
     },
-    [clearHoverTimer, openProfilePanel, pubkey],
+    [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey],
   );
 
+  const handleMessage = React.useCallback(async () => {
+    if (!showProfileActions || pendingAction !== null) return;
+
+    clearHoverTimer();
+    setPendingAction("message");
+
+    try {
+      const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+      await goChannel(dm.id);
+      if (isMountedRef.current) {
+        setOpen(false);
+      }
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to open direct message.",
+      );
+    } finally {
+      if (isMountedRef.current) {
+        setPendingAction(null);
+      }
+    }
+  }, [
+    clearHoverTimer,
+    goChannel,
+    openDmMutation,
+    pendingAction,
+    pubkey,
+    showProfileActions,
+  ]);
+
+  const handleHuddle = React.useCallback(async () => {
+    if (!showProfileActions || pendingAction !== null || isStartingHuddle) {
+      return;
+    }
+
+    clearHoverTimer();
+    setPendingAction("huddle");
+
+    try {
+      const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+      await goChannel(dm.id);
+      await startHuddle(dm.id, []);
+      await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
+      if (isMountedRef.current) {
+        setOpen(false);
+      }
+    } catch (error) {
+      toast.error(
+        error instanceof Error ? error.message : "Failed to start huddle.",
+      );
+    } finally {
+      if (isMountedRef.current) {
+        setPendingAction(null);
+      }
+    }
+  }, [
+    clearHoverTimer,
+    goChannel,
+    isStartingHuddle,
+    openDmMutation,
+    pendingAction,
+    pubkey,
+    queryClient,
+    showProfileActions,
+    startHuddle,
+  ]);
+
   React.useEffect(() => {
-    return () => clearHoverTimer();
+    isMountedRef.current = true;
+
+    return () => {
+      isMountedRef.current = false;
+      clearHoverTimer();
+    };
   }, [clearHoverTimer]);
 
   const TriggerElement = triggerElement;
@@ -163,11 +309,15 @@ export function UserProfilePopover({
     <Popover onOpenChange={setOpen} open={open}>
       <PopoverAnchor asChild>
         <TriggerElement
-          role="button"
-          tabIndex={0}
+          role={canOpenProfilePanel ? "button" : undefined}
+          tabIndex={canOpenProfilePanel ? 0 : undefined}
           onClick={handleTriggerClick}
           onKeyDown={(e) => {
-            if ((e.key === "Enter" || e.key === " ") && openProfilePanel) {
+            if (
+              (e.key === "Enter" || e.key === " ") &&
+              canOpenProfilePanel &&
+              openProfilePanel
+            ) {
               e.preventDefault();
               e.stopPropagation();
               clearHoverTimer();
@@ -192,32 +342,22 @@ export function UserProfilePopover({
         sideOffset={8}
       >
         <div className="flex flex-col gap-3">
-          <div className="flex items-start gap-3">
-            {profile?.avatarUrl ? (
-              <img
-                alt={profile.displayName ?? "User avatar"}
-                className="h-10 w-10 shrink-0 rounded-lg object-cover shadow-xs"
-                referrerPolicy="no-referrer"
-                // The popover only shows while hovering, so animated avatars
-                // play their animation here instead of the static poster frame.
-                src={rewriteRelayUrl(
-                  parseAnimatedAvatarUrl(profile.avatarUrl)?.animationUrl ??
-                    profile.avatarUrl,
-                )}
-              />
-            ) : (
-              <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-xs font-semibold text-secondary-foreground shadow-xs">
-                {(profile?.displayName ?? pubkey.slice(0, 2))
-                  .slice(0, 2)
-                  .toUpperCase()}
-              </div>
-            )}
+          <div className="flex items-center gap-3">
+            <ProfileAvatarWithStatus
+              avatarClassName="text-xs"
+              avatarUrl={profile?.avatarUrl ?? null}
+              className="h-10 w-10"
+              iconClassName="h-5 w-5"
+              label={displayName}
+              size={40}
+              status={presenceStatus ?? "offline"}
+              statusTestId="user-profile-popover-presence-badge"
+              testId="user-profile-popover-avatar"
+            />
 
             <div className="min-w-0 flex-1">
               <div className="flex items-center gap-1.5">
-                <p className="truncate text-sm font-semibold">
-                  {profile?.displayName ?? truncatePubkey(pubkey)}
-                </p>
+                <HoverPubkeyName displayName={displayName} pubkey={pubkey} />
                 {role === "bot" && botIdenticonValue ? (
                   <BotIdenticon
                     value={botIdenticonValue}
@@ -226,36 +366,17 @@ export function UserProfilePopover({
                   />
                 ) : null}
               </div>
-              {profile?.nip05Handle ? (
-                <p className="truncate text-xs text-muted-foreground">
-                  {profile.nip05Handle}
-                </p>
-              ) : null}
-              {profile?.displayName ? (
-                <p className="truncate font-mono text-2xs text-muted-foreground/50">
-                  {truncatePubkey(pubkey)}
+              {profileSubheader ? (
+                <p
+                  className="mt-0.5 truncate text-xs leading-4 text-muted-foreground"
+                  data-testid="user-profile-description"
+                >
+                  {profileSubheader}
                 </p>
               ) : null}
             </div>
-
-            {presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
           </div>
 
-          {userStatus ? (
-            <p
-              className="text-xs text-muted-foreground"
-              data-testid="user-profile-status"
-            >
-              {userStatus.emoji ? (
-                <StatusEmoji
-                  className="mr-1 h-3.5 w-3.5"
-                  value={userStatus.emoji}
-                />
-              ) : null}
-              {userStatus.text}
-            </p>
-          ) : null}
-
           {role === "bot" && (managedAgent || relayAgent) ? (
             <div className="flex flex-wrap gap-1.5">
               {managedAgent?.agentCommand ? (
@@ -284,12 +405,6 @@ export function UserProfilePopover({
             </div>
           ) : null}
 
-          {profile?.about ? (
-            <p className="text-xs leading-relaxed text-muted-foreground">
-              {profile.about}
-            </p>
-          ) : null}
-
           {canViewActivity ? (
             <button
               className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
@@ -304,6 +419,80 @@ export function UserProfilePopover({
               View activity log
             </button>
           ) : null}
+
+          {hasUserStatus || showProfileActions ? (
+            <>
+              <div
+                aria-hidden="true"
+                className="my-1 border-t border-border/60"
+              />
+              {hasUserStatus ? (
+                <StatusLine>
+                  {userStatus?.emoji ? (
+                    <StatusEmoji
+                      className="h-3.5 w-3.5 shrink-0"
+                      value={userStatus.emoji}
+                    />
+                  ) : null}
+                  {userStatusText ? (
+                    <span className="truncate">{userStatusText}</span>
+                  ) : null}
+                </StatusLine>
+              ) : null}
+              {showProfileActions ? (
+                <div className="grid grid-cols-2 gap-2">
+                  <Button
+                    className="w-full"
+                    data-testid={`user-profile-popover-message-${pubkey}`}
+                    disabled={
+                      pendingAction !== null || openDmMutation.isPending
+                    }
+                    onClick={() => {
+                      void handleMessage();
+                    }}
+                    size="sm"
+                    type="button"
+                    variant="outline"
+                  >
+                    {pendingAction === "message" ? (
+                      <Spinner
+                        aria-hidden="true"
+                        className="h-3.5 w-3.5 border-2"
+                      />
+                    ) : (
+                      <MessageSquare />
+                    )}
+                    Message
+                  </Button>
+                  <Button
+                    className="w-full"
+                    data-testid={`user-profile-popover-huddle-${pubkey}`}
+                    disabled={
+                      pendingAction !== null ||
+                      openDmMutation.isPending ||
+                      isStartingHuddle
+                    }
+                    onClick={() => {
+                      void handleHuddle();
+                    }}
+                    size="sm"
+                    type="button"
+                    variant="outline"
+                  >
+                    {pendingAction === "huddle" ? (
+                      <Spinner
+                        aria-hidden="true"
+                        className="h-3.5 w-3.5 border-2"
+                      />
+                    ) : (
+                      <Headphones />
+                    )}
+                    Huddle
+                  </Button>
+                </div>
+              ) : null}
+            </>
+          ) : null}
         </div>
       </PopoverContent>
     </Popover>
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx
index 723e58f485..d435553b41 100644
--- a/desktop/src/features/sidebar/ui/SidebarSection.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx
@@ -20,7 +20,11 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn
 import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
 import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
 import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+  DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+  ProfileAvatarWithStatus,
+  scaleProfileAvatarStatusGeometry,
+} from "@/features/profile/ui/ProfileAvatarWithStatus";
 import type { Channel, PresenceStatus } from "@/shared/api/types";
 import { cn } from "@/shared/lib/cn";
 import { useNow } from "@/shared/lib/useNow";
@@ -33,8 +37,6 @@ import {
   SidebarMenuItem,
 } from "@/shared/ui/sidebar";
 
-import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
-
 const SECTION_LABEL_BUTTON_CLASS =
   "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
 const SECTION_LABEL_CHEVRON_CLASS =
@@ -47,6 +49,11 @@ const SIDEBAR_ROW_ACTION_REPLACED_BADGE_CLASS =
   "max-md:opacity-0 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0";
 const SIDEBAR_ROW_ICON_ACTION_CLASS =
   "flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0";
+const DM_AVATAR_SIZE = 24;
+const DM_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
+  DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+  DM_AVATAR_SIZE,
+);
 
 function formatUnreadCount(count: number): string {
   return count > 99 ? "99+" : String(count);
@@ -151,7 +158,7 @@ function DmChannelIcon({
     return (
       <span
         aria-hidden="true"
-        className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-2xs font-semibold leading-none text-sidebar-foreground shadow-none"
+        className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-sidebar-accent/80 text-2xs font-semibold leading-none text-sidebar-foreground shadow-none"
         data-testid={`channel-dm-count-${channelName}`}
       >
         <span className="translate-x-px leading-none">
@@ -163,22 +170,18 @@ function DmChannelIcon({
 
   if (isPair || !participants || participants.length <= 1) {
     return (
-      <span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
-        <ProfileAvatar
+      <span className="relative flex h-6 w-6 shrink-0 items-center justify-center">
+        <ProfileAvatarWithStatus
+          avatarClassName="bg-sidebar-accent/80 text-2xs text-sidebar-foreground shadow-none"
           avatarUrl={primaryParticipant.avatarUrl}
-          className="h-5 w-5 rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-2xs text-sidebar-foreground shadow-none"
-          iconClassName="h-3 w-3"
+          className="h-6 w-6"
+          geometry={DM_AVATAR_STATUS_GEOMETRY}
+          iconClassName="h-3.5 w-3.5"
           label={primaryParticipant.label}
+          size={DM_AVATAR_SIZE}
+          status={presenceStatus}
+          statusTestId={`channel-presence-${channelName}`}
         />
-        {presenceStatus ? (
-          <span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-sidebar">
-            <PresenceDot
-              className="h-1.5 w-1.5"
-              data-testid={`channel-presence-${channelName}`}
-              status={presenceStatus}
-            />
-          </span>
-        ) : null}
       </span>
     );
   }

From 01290d3ba7fe4c9bfd078c4ecf1836e6ed66d903 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 10:33:45 +0100
Subject: [PATCH 02/15] Add profile hover wave action

---
 .../profile/ui/UserProfilePopover.tsx         | 81 +++++++++++++++++--
 desktop/tests/e2e/mentions.spec.ts            | 53 ++++++++++++
 2 files changed, 128 insertions(+), 6 deletions(-)

diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 780bb56dd8..6d8af80263 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -10,7 +10,8 @@ import {
   useChannelsQuery,
   useOpenDmMutation,
 } from "@/features/channels/hooks";
-import { useUserProfileQuery } from "@/features/profile/hooks";
+import { useProfileQuery, useUserProfileQuery } from "@/features/profile/hooks";
+import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
 import {
   useRelayAgentsQuery,
   useManagedAgentsQuery,
@@ -26,6 +27,7 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus";
 import { useAgentSession } from "@/shared/context/AgentSessionContext";
 import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
+import { sendChannelMessage } from "@/shared/api/tauri";
 
 import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
 import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -120,7 +122,7 @@ export function UserProfilePopover({
 }: UserProfilePopoverProps) {
   const [open, setOpen] = React.useState(false);
   const [pendingAction, setPendingAction] = React.useState<
-    "message" | "huddle" | null
+    "message" | "huddle" | "wave" | null
   >(null);
   const isMountedRef = React.useRef(false);
   const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
@@ -159,11 +161,13 @@ export function UserProfilePopover({
   // this only decides whether to paint the "View activity log" button.
   const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
   const ownerPubkey = profile?.ownerPubkey ?? null;
-  const currentPubkey = useIdentityQuery().data?.pubkey;
+  const identityQuery = useIdentityQuery();
+  const currentPubkey = identityQuery.data?.pubkey;
   const isSelf =
     currentPubkey !== undefined &&
     currentPubkey.toLowerCase() === pubkey.toLowerCase();
   const showProfileActions = currentPubkey !== undefined && !isSelf;
+  const selfProfileQuery = useProfileQuery(open && showProfileActions);
   const isCurrentUserOwner =
     currentPubkey !== undefined &&
     ownerPubkey !== null &&
@@ -294,6 +298,46 @@ export function UserProfilePopover({
     startHuddle,
   ]);
 
+  const handleWave = React.useCallback(async () => {
+    if (!showProfileActions || pendingAction !== null) return;
+
+    clearHoverTimer();
+    setPendingAction("wave");
+
+    try {
+      const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+      const senderName =
+        selfProfileQuery.data?.displayName?.trim() ||
+        (currentPubkey ? truncatePubkey(currentPubkey) : "Someone");
+      await sendChannelMessage(dm.id, `${senderName} waved at you.`);
+      await queryClient.invalidateQueries({
+        queryKey: channelMessagesKey(dm.id),
+      });
+      await goChannel(dm.id);
+      if (isMountedRef.current) {
+        setOpen(false);
+      }
+    } catch (error) {
+      toast.error(
+        error instanceof Error ? error.message : "Failed to send wave.",
+      );
+    } finally {
+      if (isMountedRef.current) {
+        setPendingAction(null);
+      }
+    }
+  }, [
+    clearHoverTimer,
+    currentPubkey,
+    goChannel,
+    openDmMutation,
+    pendingAction,
+    pubkey,
+    queryClient,
+    selfProfileQuery.data?.displayName,
+    showProfileActions,
+  ]);
+
   React.useEffect(() => {
     isMountedRef.current = true;
 
@@ -440,9 +484,9 @@ export function UserProfilePopover({
                 </StatusLine>
               ) : null}
               {showProfileActions ? (
-                <div className="grid grid-cols-2 gap-2">
+                <div className="flex gap-2">
                   <Button
-                    className="w-full"
+                    className="min-w-0 flex-1"
                     data-testid={`user-profile-popover-message-${pubkey}`}
                     disabled={
                       pendingAction !== null || openDmMutation.isPending
@@ -465,7 +509,7 @@ export function UserProfilePopover({
                     Message
                   </Button>
                   <Button
-                    className="w-full"
+                    className="min-w-0 flex-1"
                     data-testid={`user-profile-popover-huddle-${pubkey}`}
                     disabled={
                       pendingAction !== null ||
@@ -489,6 +533,31 @@ export function UserProfilePopover({
                     )}
                     Huddle
                   </Button>
+                  <Button
+                    className="shrink-0 px-3"
+                    data-testid={`user-profile-popover-wave-${pubkey}`}
+                    disabled={
+                      pendingAction !== null || openDmMutation.isPending
+                    }
+                    onClick={() => {
+                      void handleWave();
+                    }}
+                    size="sm"
+                    type="button"
+                    variant="outline"
+                  >
+                    {pendingAction === "wave" ? (
+                      <Spinner
+                        aria-hidden="true"
+                        className="h-3.5 w-3.5 border-2"
+                      />
+                    ) : (
+                      <span aria-hidden="true" className="text-sm leading-none">
+                        👋
+                      </span>
+                    )}
+                    Wave
+                  </Button>
                 </div>
               ) : null}
             </>
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 1e73743e51..dff92c9726 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -42,6 +42,21 @@ async function readCommandLog(page: import("@playwright/test").Page) {
   });
 }
 
+async function readCommandPayloadLog(page: import("@playwright/test").Page) {
+  return page.evaluate(() => {
+    return (
+      (
+        window as Window & {
+          __BUZZ_E2E_COMMAND_LOG__?: Array<{
+            command: string;
+            payload: unknown;
+          }>;
+        }
+      ).__BUZZ_E2E_COMMAND_LOG__ ?? []
+    );
+  });
+}
+
 function commandCount(commands: string[], command: string) {
   return commands.filter((entry) => entry === command).length;
 }
@@ -1171,3 +1186,41 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({
   await expect(profilePopover).toHaveCount(0);
   await expect(page.getByTestId("user-profile-panel")).toBeVisible();
 });
+
+test("profile popover wave sends a direct message", async ({ page }) => {
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+  const aliceMessage = page
+    .getByTestId("message-row")
+    .filter({ hasText: "Hey team — checking in." })
+    .first();
+  await aliceMessage.locator("button").first().hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-wave-${TEST_IDENTITIES.alice.pubkey}`)
+    .click();
+
+  await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
+  await waitForTimelineSettled(page);
+  await expect(page.getByTestId("message-row").last()).toContainText(
+    "npub1mock... waved at you.",
+  );
+
+  const commandLog = await readCommandPayloadLog(page);
+  expect(commandLog).toEqual(
+    expect.arrayContaining([
+      expect.objectContaining({
+        command: "send_channel_message",
+        payload: expect.objectContaining({
+          content: "npub1mock... waved at you.",
+        }),
+      }),
+    ]),
+  );
+});

From cbeecede0a355d261c9d0e82a9118cb36399a19f Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 10:47:38 +0100
Subject: [PATCH 03/15] Render wave messages as attachments

---
 .../src/features/messages/lib/waveMessage.ts  |  26 +++
 .../src/features/messages/ui/MessageRow.tsx   |  14 ++
 .../messages/ui/WaveMessageAttachment.tsx     |  82 ++++++++
 .../profile/ui/UserProfilePopover.tsx         |  49 ++---
 desktop/src/shared/ui/attachment.tsx          | 196 ++++++++++++++++++
 desktop/tests/e2e/mentions.spec.ts            |  23 +-
 6 files changed, 362 insertions(+), 28 deletions(-)
 create mode 100644 desktop/src/features/messages/lib/waveMessage.ts
 create mode 100644 desktop/src/features/messages/ui/WaveMessageAttachment.tsx
 create mode 100644 desktop/src/shared/ui/attachment.tsx

diff --git a/desktop/src/features/messages/lib/waveMessage.ts b/desktop/src/features/messages/lib/waveMessage.ts
new file mode 100644
index 0000000000..7ef320dd31
--- /dev/null
+++ b/desktop/src/features/messages/lib/waveMessage.ts
@@ -0,0 +1,26 @@
+export const WAVE_MESSAGE_MARKER = "<!-- buzz:wave:v1 -->";
+
+export type WaveMessageContent = {
+  fallbackText: string;
+};
+
+export function buildWaveMessageContent(senderName: string): string {
+  const trimmedName = senderName.trim() || "Someone";
+  return `${WAVE_MESSAGE_MARKER}\n${trimmedName} waved at you.`;
+}
+
+export function parseWaveMessageContent(
+  content: string,
+): WaveMessageContent | null {
+  const trimmedContent = content.trimStart();
+
+  if (!trimmedContent.startsWith(WAVE_MESSAGE_MARKER)) {
+    return null;
+  }
+
+  const fallbackText = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim();
+
+  return {
+    fallbackText: fallbackText || "Someone waved at you.",
+  };
+}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 16c3ceed72..30959aa24f 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -22,6 +22,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
 import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
 import { parseImetaTags } from "@/features/messages/lib/parseImeta";
 import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
+import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
 import {
   resolveMentionNames,
   resolveMentionPubkeysByName,
@@ -31,6 +32,7 @@ import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
 import { MessageActionBar } from "./MessageActionBar";
 import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
 import { MessageTimestamp } from "./MessageTimestamp";
+import { WaveMessageAttachment } from "./WaveMessageAttachment";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
 
 const DiffMessage = React.lazy(() => import("./DiffMessage"));
@@ -275,6 +277,18 @@ export const MessageRow = React.memo(
             </React.Suspense>
           );
         default:
+          {
+            const waveMessage = parseWaveMessageContent(message.body);
+            if (waveMessage) {
+              return (
+                <WaveMessageAttachment
+                  channelId={channelId}
+                  fallbackText={waveMessage.fallbackText}
+                />
+              );
+            }
+          }
+
           return (
             <Markdown
               channelNames={channelNames}
diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
new file mode 100644
index 0000000000..89110db563
--- /dev/null
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -0,0 +1,82 @@
+import * as React from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+import { channelsQueryKey } from "@/features/channels/hooks";
+import { useHuddle } from "@/features/huddle";
+import {
+  Attachment,
+  AttachmentAction,
+  AttachmentActions,
+  AttachmentContent,
+  AttachmentDescription,
+  AttachmentMedia,
+  AttachmentTitle,
+} from "@/shared/ui/attachment";
+
+type WaveMessageAttachmentProps = {
+  channelId?: string | null;
+  fallbackText: string;
+};
+
+export function WaveMessageAttachment({
+  channelId,
+  fallbackText,
+}: WaveMessageAttachmentProps) {
+  const queryClient = useQueryClient();
+  const { isStarting, startHuddle } = useHuddle();
+
+  const handleStartHuddle = React.useCallback(
+    async (event: React.MouseEvent<HTMLButtonElement>) => {
+      event.preventDefault();
+      event.stopPropagation();
+
+      if (!channelId || isStarting) {
+        return;
+      }
+
+      try {
+        await startHuddle(channelId, []);
+        await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
+      } catch (error) {
+        toast.error(
+          error instanceof Error ? error.message : "Failed to start huddle.",
+        );
+      }
+    },
+    [channelId, isStarting, queryClient, startHuddle],
+  );
+
+  return (
+    <Attachment
+      className="mt-1 max-w-md"
+      data-testid="message-wave-attachment"
+      size="default"
+    >
+      <AttachmentMedia
+        aria-hidden="true"
+        className="bg-primary/10 text-2xl text-foreground"
+        variant="image"
+      >
+        👋
+      </AttachmentMedia>
+      <AttachmentContent>
+        <AttachmentTitle>{fallbackText}</AttachmentTitle>
+        <AttachmentDescription>
+          Start a huddle to talk to them.
+        </AttachmentDescription>
+      </AttachmentContent>
+      <AttachmentActions>
+        <AttachmentAction
+          disabled={!channelId || isStarting}
+          onClick={handleStartHuddle}
+          size="xs"
+          type="button"
+          variant="outline"
+        >
+          Start huddle
+        </AttachmentAction>
+      </AttachmentActions>
+    </Attachment>
+  );
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 6d8af80263..f4ce80c759 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -25,6 +25,7 @@ import { usePresenceQuery } from "@/features/presence/hooks";
 import { useUserStatusQuery } from "@/features/user-status/hooks";
 import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus";
+import { buildWaveMessageContent } from "@/features/messages/lib/waveMessage";
 import { useAgentSession } from "@/shared/context/AgentSessionContext";
 import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
 import { sendChannelMessage } from "@/shared/api/tauri";
@@ -309,7 +310,7 @@ export function UserProfilePopover({
       const senderName =
         selfProfileQuery.data?.displayName?.trim() ||
         (currentPubkey ? truncatePubkey(currentPubkey) : "Someone");
-      await sendChannelMessage(dm.id, `${senderName} waved at you.`);
+      await sendChannelMessage(dm.id, buildWaveMessageContent(senderName));
       await queryClient.invalidateQueries({
         queryKey: channelMessagesKey(dm.id),
       });
@@ -486,77 +487,77 @@ export function UserProfilePopover({
               {showProfileActions ? (
                 <div className="flex gap-2">
                   <Button
-                    className="min-w-0 flex-1"
-                    data-testid={`user-profile-popover-message-${pubkey}`}
+                    aria-label="Wave"
+                    className="shrink-0 px-3"
+                    data-testid={`user-profile-popover-wave-${pubkey}`}
                     disabled={
                       pendingAction !== null || openDmMutation.isPending
                     }
                     onClick={() => {
-                      void handleMessage();
+                      void handleWave();
                     }}
                     size="sm"
                     type="button"
                     variant="outline"
                   >
-                    {pendingAction === "message" ? (
+                    {pendingAction === "wave" ? (
                       <Spinner
                         aria-hidden="true"
                         className="h-3.5 w-3.5 border-2"
                       />
                     ) : (
-                      <MessageSquare />
+                      <span aria-hidden="true" className="text-sm leading-none">
+                        👋
+                      </span>
                     )}
-                    Message
                   </Button>
                   <Button
                     className="min-w-0 flex-1"
-                    data-testid={`user-profile-popover-huddle-${pubkey}`}
+                    data-testid={`user-profile-popover-message-${pubkey}`}
                     disabled={
-                      pendingAction !== null ||
-                      openDmMutation.isPending ||
-                      isStartingHuddle
+                      pendingAction !== null || openDmMutation.isPending
                     }
                     onClick={() => {
-                      void handleHuddle();
+                      void handleMessage();
                     }}
                     size="sm"
                     type="button"
                     variant="outline"
                   >
-                    {pendingAction === "huddle" ? (
+                    {pendingAction === "message" ? (
                       <Spinner
                         aria-hidden="true"
                         className="h-3.5 w-3.5 border-2"
                       />
                     ) : (
-                      <Headphones />
+                      <MessageSquare />
                     )}
-                    Huddle
+                    Message
                   </Button>
                   <Button
-                    className="shrink-0 px-3"
-                    data-testid={`user-profile-popover-wave-${pubkey}`}
+                    className="min-w-0 flex-1"
+                    data-testid={`user-profile-popover-huddle-${pubkey}`}
                     disabled={
-                      pendingAction !== null || openDmMutation.isPending
+                      pendingAction !== null ||
+                      openDmMutation.isPending ||
+                      isStartingHuddle
                     }
                     onClick={() => {
-                      void handleWave();
+                      void handleHuddle();
                     }}
                     size="sm"
                     type="button"
                     variant="outline"
                   >
-                    {pendingAction === "wave" ? (
+                    {pendingAction === "huddle" ? (
                       <Spinner
                         aria-hidden="true"
                         className="h-3.5 w-3.5 border-2"
                       />
                     ) : (
-                      <span aria-hidden="true" className="text-sm leading-none">
-                        👋
-                      </span>
+                      <Headphones />
                     )}
-                    Wave
+                    Huddle
                   </Button>
                 </div>
               ) : null}
diff --git a/desktop/src/shared/ui/attachment.tsx b/desktop/src/shared/ui/attachment.tsx
new file mode 100644
index 0000000000..b3765415d3
--- /dev/null
+++ b/desktop/src/shared/ui/attachment.tsx
@@ -0,0 +1,196 @@
+import type * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+
+import { cn } from "@/shared/lib/cn";
+import { Button, type ButtonProps } from "@/shared/ui/button";
+
+type AttachmentProps = React.ComponentProps<"div"> & {
+  orientation?: "horizontal" | "vertical";
+  size?: "default" | "sm" | "xs";
+  state?: "idle" | "uploading" | "processing" | "error" | "done";
+};
+
+function Attachment({
+  className,
+  orientation = "horizontal",
+  size = "default",
+  state = "done",
+  ...props
+}: AttachmentProps) {
+  return (
+    <div
+      className={cn(
+        "group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-lg border border-border/70 bg-muted/30 text-left transition-colors",
+        "hover:border-border hover:bg-muted/50 data-[state=error]:border-destructive/40 data-[state=error]:bg-destructive/10",
+        "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
+        orientation === "horizontal" && "items-center",
+        orientation === "vertical" && "flex-col",
+        size === "default" && "px-3 py-2.5",
+        size === "sm" && "px-2.5 py-2",
+        size === "xs" && "gap-2 px-2 py-1.5",
+        className,
+      )}
+      data-orientation={orientation}
+      data-slot="attachment"
+      data-state={state}
+      {...props}
+    />
+  );
+}
+
+type AttachmentMediaProps = React.ComponentProps<"div"> & {
+  variant?: "icon" | "image";
+};
+
+function AttachmentMedia({
+  className,
+  variant = "icon",
+  ...props
+}: AttachmentMediaProps) {
+  return (
+    <div
+      className={cn(
+        "flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground",
+        variant === "icon" && "h-9 w-9",
+        variant === "image" && "aspect-square h-12 w-12",
+        "[&_svg]:h-4 [&_svg]:w-4",
+        className,
+      )}
+      data-slot="attachment-media"
+      data-variant={variant}
+      {...props}
+    />
+  );
+}
+
+function AttachmentContent({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn("min-w-0 flex-1", className)}
+      data-slot="attachment-content"
+      {...props}
+    />
+  );
+}
+
+function AttachmentTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "truncate text-sm font-semibold leading-5 text-foreground group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:animate-pulse",
+        className,
+      )}
+      data-slot="attachment-title"
+      {...props}
+    />
+  );
+}
+
+function AttachmentDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "truncate text-xs leading-4 text-muted-foreground",
+        className,
+      )}
+      data-slot="attachment-description"
+      {...props}
+    />
+  );
+}
+
+function AttachmentActions({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "relative z-20 flex shrink-0 items-center gap-1",
+        className,
+      )}
+      data-slot="attachment-actions"
+      {...props}
+    />
+  );
+}
+
+type AttachmentActionProps = ButtonProps & {
+  asChild?: boolean;
+};
+
+function AttachmentAction({
+  className,
+  size = "icon-xs",
+  variant = "ghost",
+  ...props
+}: AttachmentActionProps) {
+  return (
+    <Button
+      className={cn(
+        "relative z-20 text-muted-foreground hover:bg-muted hover:text-foreground",
+        className,
+      )}
+      data-slot="attachment-action"
+      size={size}
+      variant={variant}
+      {...props}
+    />
+  );
+}
+
+type AttachmentTriggerProps = React.ComponentProps<"button"> & {
+  asChild?: boolean;
+};
+
+function AttachmentTrigger({
+  asChild,
+  className,
+  type = "button",
+  ...props
+}: AttachmentTriggerProps) {
+  const Comp = asChild ? Slot : "button";
+
+  return (
+    <Comp
+      className={cn(
+        "absolute inset-0 z-10 rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
+        className,
+      )}
+      data-slot="attachment-trigger"
+      type={asChild ? undefined : type}
+      {...props}
+    />
+  );
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      className={cn(
+        "flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1",
+        className,
+      )}
+      data-slot="attachment-group"
+      {...props}
+    />
+  );
+}
+
+export {
+  Attachment,
+  AttachmentAction,
+  AttachmentActions,
+  AttachmentContent,
+  AttachmentDescription,
+  AttachmentGroup,
+  AttachmentMedia,
+  AttachmentTitle,
+  AttachmentTrigger,
+};
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index dff92c9726..fe15027cef 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1208,9 +1208,14 @@ test("profile popover wave sends a direct message", async ({ page }) => {
 
   await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
   await waitForTimelineSettled(page);
-  await expect(page.getByTestId("message-row").last()).toContainText(
-    "npub1mock... waved at you.",
-  );
+  const waveAttachment = page.getByTestId("message-wave-attachment");
+  await expect(waveAttachment).toBeVisible();
+  await expect(waveAttachment).toContainText("👋");
+  await expect(waveAttachment).toContainText("npub1mock... waved at you.");
+  await expect(waveAttachment).toContainText("Start a huddle to talk to them.");
+  await expect(
+    waveAttachment.getByRole("button", { name: "Start huddle" }),
+  ).toBeVisible();
 
   const commandLog = await readCommandPayloadLog(page);
   expect(commandLog).toEqual(
@@ -1218,7 +1223,17 @@ test("profile popover wave sends a direct message", async ({ page }) => {
       expect.objectContaining({
         command: "send_channel_message",
         payload: expect.objectContaining({
-          content: "npub1mock... waved at you.",
+          content: expect.stringContaining("npub1mock... waved at you."),
+        }),
+      }),
+    ]),
+  );
+  expect(commandLog).toEqual(
+    expect.arrayContaining([
+      expect.objectContaining({
+        command: "send_channel_message",
+        payload: expect.objectContaining({
+          content: expect.stringContaining("<!-- buzz:wave:v1 -->"),
         }),
       }),
     ]),

From 1e9ed790421cac82a00896660b3ae373ab6040df Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:02:27 +0100
Subject: [PATCH 04/15] Animate wave affordances

---
 .../messages/ui/WaveMessageAttachment.tsx     |  5 +--
 .../profile/ui/UserProfilePopover.tsx         |  7 +++-
 desktop/src/shared/styles/globals.css         | 41 +++++++++++++++++++
 3 files changed, 48 insertions(+), 5 deletions(-)

diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index 89110db563..f644a6c9f8 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -49,7 +49,7 @@ export function WaveMessageAttachment({
 
   return (
     <Attachment
-      className="mt-1 max-w-md"
+      className="buzz-wave-hover-trigger mt-1 max-w-md"
       data-testid="message-wave-attachment"
       size="default"
     >
@@ -58,7 +58,7 @@ export function WaveMessageAttachment({
         className="bg-primary/10 text-2xl text-foreground"
         variant="image"
       >
-        👋
+        <span className="buzz-wave-hand">👋</span>
       </AttachmentMedia>
       <AttachmentContent>
         <AttachmentTitle>{fallbackText}</AttachmentTitle>
@@ -72,7 +72,6 @@ export function WaveMessageAttachment({
           onClick={handleStartHuddle}
           size="xs"
           type="button"
-          variant="outline"
         >
           Start huddle
         </AttachmentAction>
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index f4ce80c759..6d48657f90 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -488,7 +488,7 @@ export function UserProfilePopover({
                 <div className="flex gap-2">
                   <Button
                     aria-label="Wave"
-                    className="shrink-0 px-3"
+                    className="buzz-wave-hover-trigger shrink-0 px-3"
                     data-testid={`user-profile-popover-wave-${pubkey}`}
                     disabled={
                       pendingAction !== null || openDmMutation.isPending
@@ -506,7 +506,10 @@ export function UserProfilePopover({
                         className="h-3.5 w-3.5 border-2"
                       />
                     ) : (
-                      <span aria-hidden="true" className="text-sm leading-none">
+                      <span
+                        aria-hidden="true"
+                        className="buzz-wave-hand text-sm leading-none"
+                      >
                         👋
                       </span>
                     )}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index e416856888..addd469e29 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -40,6 +40,47 @@
   }
 }
 
+.buzz-wave-hand {
+  display: inline-block;
+  transform-origin: 70% 70%;
+  will-change: transform;
+}
+
+@media (hover: hover) and (pointer: fine) {
+  .buzz-wave-hover-trigger:hover .buzz-wave-hand {
+    animation: buzz-wave-hand 720ms ease-in-out both;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .buzz-wave-hover-trigger:hover .buzz-wave-hand {
+    animation: none;
+  }
+}
+
+@keyframes buzz-wave-hand {
+  0%,
+  100% {
+    transform: rotate(0deg);
+  }
+
+  12%,
+  38%,
+  64% {
+    transform: rotate(15deg);
+  }
+
+  25%,
+  51%,
+  77% {
+    transform: rotate(-30deg);
+  }
+
+  88% {
+    transform: rotate(8deg);
+  }
+}
+
 .buzz-emoji-mart {
   align-items: stretch;
   display: flex;

From 18951b957deb0bfb7910012447739ba3bda7aebc Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:11:21 +0100
Subject: [PATCH 05/15] Show wave messages optimistically

---
 desktop/src/features/messages/hooks.ts        |   2 +-
 .../profile/ui/UserProfilePopover.tsx         | 114 ++++++++++++++++--
 desktop/src/testing/e2eBridge.ts              |   8 ++
 desktop/tests/e2e/mentions.spec.ts            |   7 +-
 desktop/tests/helpers/bridge.ts               |   1 +
 5 files changed, 120 insertions(+), 12 deletions(-)

diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index 4151b1b0b9..5d62f1e70d 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -118,7 +118,7 @@ export function mergeTimelineCacheMessages(
   );
 }
 
-function createOptimisticMessage(
+export function createOptimisticMessage(
   channelId: string,
   content: string,
   identity: Identity,
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 6d48657f90..1d05176d74 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -25,10 +25,17 @@ import { usePresenceQuery } from "@/features/presence/hooks";
 import { useUserStatusQuery } from "@/features/user-status/hooks";
 import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus";
+import {
+  createOptimisticMessage,
+  mergeTimelineCacheMessages,
+} from "@/features/messages/hooks";
 import { buildWaveMessageContent } from "@/features/messages/lib/waveMessage";
 import { useAgentSession } from "@/shared/context/AgentSessionContext";
 import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
 import { sendChannelMessage } from "@/shared/api/tauri";
+import type { Channel, RelayEvent } from "@/shared/api/types";
+import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 
 import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
 import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -70,6 +77,43 @@ function InfoBadge({ children }: { children: React.ReactNode }) {
   );
 }
 
+function findCachedOneToOneDm(
+  channels: Channel[] | undefined,
+  targetPubkey: string,
+  currentPubkey: string | undefined,
+) {
+  const normalizedTargetPubkey = normalizePubkey(targetPubkey);
+  const normalizedCurrentPubkey = currentPubkey
+    ? normalizePubkey(currentPubkey)
+    : null;
+
+  return (
+    channels?.find((channel) => {
+      if (channel.channelType !== "dm") {
+        return false;
+      }
+
+      const participantPubkeys =
+        channel.participantPubkeys.map(normalizePubkey);
+      if (!participantPubkeys.includes(normalizedTargetPubkey)) {
+        return false;
+      }
+
+      const otherParticipantPubkeys = normalizedCurrentPubkey
+        ? participantPubkeys.filter(
+            (participantPubkey) =>
+              participantPubkey !== normalizedCurrentPubkey,
+          )
+        : participantPubkeys;
+
+      return (
+        otherParticipantPubkeys.length === 1 &&
+        otherParticipantPubkeys[0] === normalizedTargetPubkey
+      );
+    }) ?? null
+  );
+}
+
 const TEXT_SWAP_BASE_CLASS =
   "col-start-1 row-start-1 min-w-0 truncate transition-[opacity,filter] duration-[250ms] ease-in-out motion-reduce:transition-none";
 const TEXT_SWAP_VISIBLE_CLASS = "opacity-100 blur-0";
@@ -306,17 +350,67 @@ export function UserProfilePopover({
     setPendingAction("wave");
 
     try {
-      const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+      const identity = identityQuery.data;
+      if (!identity) {
+        throw new Error("No identity available for sending messages.");
+      }
+
+      const dm =
+        findCachedOneToOneDm(channelsQuery.data, pubkey, currentPubkey) ??
+        (await openDmMutation.mutateAsync({ pubkeys: [pubkey] }));
       const senderName =
         selfProfileQuery.data?.displayName?.trim() ||
-        (currentPubkey ? truncatePubkey(currentPubkey) : "Someone");
-      await sendChannelMessage(dm.id, buildWaveMessageContent(senderName));
-      await queryClient.invalidateQueries({
-        queryKey: channelMessagesKey(dm.id),
-      });
-      await goChannel(dm.id);
-      if (isMountedRef.current) {
-        setOpen(false);
+        identity.displayName.trim() ||
+        truncatePubkey(identity.pubkey);
+      const content = buildWaveMessageContent(senderName);
+      const queryKey = channelMessagesKey(dm.id);
+
+      await queryClient.cancelQueries({ queryKey });
+      const previousMessages =
+        queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
+      const optimisticMessage = createOptimisticMessage(
+        dm.id,
+        content,
+        identity,
+        previousMessages,
+      );
+
+      queryClient.setQueryData<RelayEvent[]>(
+        queryKey,
+        mergeTimelineCacheMessages(previousMessages, optimisticMessage),
+      );
+
+      try {
+        await goChannel(dm.id);
+        if (isMountedRef.current) {
+          setOpen(false);
+        }
+
+        const result = await sendChannelMessage(dm.id, content);
+        queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
+          mergeTimelineCacheMessages(current, {
+            id: result.eventId,
+            localKey: optimisticMessage.id,
+            pubkey: identity.pubkey,
+            created_at: result.createdAt,
+            kind: KIND_STREAM_MESSAGE,
+            tags: [
+              ["h", dm.id],
+              ["p", identity.pubkey],
+            ],
+            content: content.trim(),
+            sig: "",
+          }),
+        );
+      } catch (error) {
+        queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
+          current.filter(
+            (message) =>
+              message.id !== optimisticMessage.id &&
+              message.localKey !== optimisticMessage.localKey,
+          ),
+        );
+        throw error;
       }
     } catch (error) {
       toast.error(
@@ -328,9 +422,11 @@ export function UserProfilePopover({
       }
     }
   }, [
+    channelsQuery.data,
     clearHoverTimer,
     currentPubkey,
     goChannel,
+    identityQuery.data,
     openDmMutation,
     pendingAction,
     pubkey,
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 8c9d825585..12ad7f13a8 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -86,6 +86,7 @@ type E2eConfig = {
     feedReadError?: string;
     canvasReadError?: string;
     openDmDelayMs?: number;
+    sendMessageDelayMs?: number;
     /** Delay (ms) applied to older-history (`history-` subId) fetches so e2e
      *  tests can observe the in-flight prepend window. 0/undefined = instant. */
     historyDelayMs?: number;
@@ -5554,6 +5555,13 @@ async function handleSendChannelMessage(
   config: E2eConfig | undefined,
 ): Promise<RawSendChannelMessageResponse> {
   const kind = args.kind ?? 9;
+  const sendMessageDelayMs = config?.mock?.sendMessageDelayMs ?? 0;
+  if (sendMessageDelayMs > 0) {
+    await new Promise((resolve) =>
+      window.setTimeout(resolve, sendMessageDelayMs),
+    );
+  }
+
   // NIP-92 imeta attachments. The real relay echoes these back on the stored
   // event; mirror that here so attachment renderers (FileCard, images, video)
   // have the imeta tags they key on. `null`/empty → no extra tags.
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index fe15027cef..c2cb40b92d 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1188,6 +1188,8 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({
 });
 
 test("profile popover wave sends a direct message", async ({ page }) => {
+  await installMockBridge(page, { sendMessageDelayMs: 2_500 });
+
   await page.goto("/");
   await page.getByTestId("channel-general").click();
   await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -1207,9 +1209,10 @@ test("profile popover wave sends a direct message", async ({ page }) => {
     .click();
 
   await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
-  await waitForTimelineSettled(page);
   const waveAttachment = page.getByTestId("message-wave-attachment");
-  await expect(waveAttachment).toBeVisible();
+  await expect(waveAttachment).toBeVisible({ timeout: 1_500 });
+  await expect(page.getByText("Sending")).toHaveCount(0, { timeout: 4_000 });
+  await waitForTimelineSettled(page);
   await expect(waveAttachment).toContainText("👋");
   await expect(waveAttachment).toContainText("npub1mock... waved at you.");
   await expect(waveAttachment).toContainText("Start a huddle to talk to them.");
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 8d317d7fee..e1340e896e 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -110,6 +110,7 @@ type MockBridgeOptions = {
   feedReadError?: string;
   canvasReadError?: string;
   openDmDelayMs?: number;
+  sendMessageDelayMs?: number;
   /** Delay (ms) for older-history fetches; see e2eBridge mock config. */
   historyDelayMs?: number;
   profileReadDelayMs?: number;

From 47fa7ce48b671ea1253c4a8c562f2470b81e5458 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:15:18 +0100
Subject: [PATCH 06/15] Soften wave attachment animation

---
 .../src/features/messages/ui/WaveMessageAttachment.tsx    | 6 +-----
 desktop/src/shared/styles/globals.css                     | 8 ++------
 2 files changed, 3 insertions(+), 11 deletions(-)

diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index f644a6c9f8..cc0ac62b29 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -53,11 +53,7 @@ export function WaveMessageAttachment({
       data-testid="message-wave-attachment"
       size="default"
     >
-      <AttachmentMedia
-        aria-hidden="true"
-        className="bg-primary/10 text-2xl text-foreground"
-        variant="image"
-      >
+      <AttachmentMedia aria-hidden="true" className="text-lg">
         <span className="buzz-wave-hand">👋</span>
       </AttachmentMedia>
       <AttachmentContent>
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index addd469e29..1ffdc04b83 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -48,7 +48,7 @@
 
 @media (hover: hover) and (pointer: fine) {
   .buzz-wave-hover-trigger:hover .buzz-wave-hand {
-    animation: buzz-wave-hand 720ms ease-in-out both;
+    animation: buzz-wave-hand 960ms ease-in-out both;
   }
 }
 
@@ -73,11 +73,7 @@
   25%,
   51%,
   77% {
-    transform: rotate(-30deg);
-  }
-
-  88% {
-    transform: rotate(8deg);
+    transform: rotate(-15deg);
   }
 }
 

From cdcb3a36225940cd955d924a58e8d2ace3b5d3e5 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:34:14 +0100
Subject: [PATCH 07/15] Align wave huddle action accessory

---
 desktop/src/features/messages/ui/WaveMessageAttachment.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index cc0ac62b29..e7116ef882 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -64,6 +64,7 @@ export function WaveMessageAttachment({
       </AttachmentContent>
       <AttachmentActions>
         <AttachmentAction
+          className="opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
           disabled={!channelId || isStarting}
           onClick={handleStartHuddle}
           size="xs"

From 30e8fd39032ba16fe96f6be73d689001bcfec857 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:36:25 +0100
Subject: [PATCH 08/15] Slow wave hover animation

---
 desktop/src/shared/styles/globals.css | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 1ffdc04b83..6f2e44192c 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -48,7 +48,7 @@
 
 @media (hover: hover) and (pointer: fine) {
   .buzz-wave-hover-trigger:hover .buzz-wave-hand {
-    animation: buzz-wave-hand 960ms ease-in-out both;
+    animation: buzz-wave-hand 1200ms ease-in-out both;
   }
 }
 

From 59f8b2d44cc6c744d682cbe6ac8df1515d60c180 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 11:41:36 +0100
Subject: [PATCH 09/15] Show wave huddle action by default

---
 desktop/src/features/messages/ui/WaveMessageAttachment.tsx | 1 -
 1 file changed, 1 deletion(-)

diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index e7116ef882..cc0ac62b29 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -64,7 +64,6 @@ export function WaveMessageAttachment({
       </AttachmentContent>
       <AttachmentActions>
         <AttachmentAction
-          className="opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
           disabled={!channelId || isStarting}
           onClick={handleStartHuddle}
           size="xs"

From d1f6b3601eb8c89b0349825028f33c023e89db3d Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 12:01:38 +0100
Subject: [PATCH 10/15] Address profile hover review feedback

---
 .../src/features/channels/ui/ChannelPane.tsx  | 30 +++++++
 .../features/channels/ui/ChannelScreen.tsx    |  9 ++-
 .../src/features/messages/ui/MessageRow.tsx   | 19 ++++-
 .../messages/ui/MessageThreadPanel.tsx        |  4 +
 .../features/messages/ui/MessageTimeline.tsx  |  3 +
 .../messages/ui/TimelineMessageList.tsx       |  8 ++
 .../messages/ui/WaveMessageAttachment.tsx     |  6 +-
 .../profile/ui/ProfileAvatarWithStatus.tsx    |  6 +-
 .../profile/ui/UserProfilePopover.tsx         | 18 +++--
 desktop/tests/e2e/mentions.spec.ts            | 79 +++++++++++++++++++
 10 files changed, 166 insertions(+), 16 deletions(-)

diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index c0c5b19b0b..b0d804c35a 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -65,6 +65,7 @@ import type { Channel } from "@/shared/api/types";
 import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
 import { channelChrome } from "@/shared/layout/chromeLayout";
 import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 type ChannelPaneProps = {
   activeChannel: Channel | null;
   activityAgents?: BotActivityAgent[];
@@ -274,6 +275,33 @@ export const ChannelPane = React.memo(function ChannelPane({
     !activeChannel.archivedAt;
   const hasMainComposerOverlay = !isNonMemberView;
   const activeChannelId = activeChannel?.id ?? null;
+  const huddleMemberPubkeys = React.useMemo(() => {
+    if (activeChannel?.channelType !== "dm" || !agentPubkeys) {
+      return [];
+    }
+
+    const normalizedCurrentPubkey = currentPubkey
+      ? normalizePubkey(currentPubkey)
+      : null;
+    const seen = new Set<string>();
+
+    return activeChannel.participantPubkeys.filter((pubkey) => {
+      const normalizedPubkey = normalizePubkey(pubkey);
+      if (
+        normalizedCurrentPubkey &&
+        normalizedPubkey === normalizedCurrentPubkey
+      ) {
+        return false;
+      }
+
+      if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) {
+        return false;
+      }
+
+      seen.add(normalizedPubkey);
+      return true;
+    });
+  }, [activeChannel, agentPubkeys, currentPubkey]);
   const isActiveWelcomeChannel =
     activeChannel !== null && isWelcomeChannel(activeChannel);
   useComposerHeightPadding(
@@ -677,6 +705,7 @@ export const ChannelPane = React.memo(function ChannelPane({
             followThreadById={followThreadById}
             hasComposerOverlay={hasMainComposerOverlay}
             hasOlderMessages={hasOlderMessages}
+            huddleMemberPubkeys={huddleMemberPubkeys}
             isFetchingOlder={isFetchingOlder}
             isFollowingThreadById={isFollowingThreadById}
             isMessageUnreadById={isMessageUnreadById}
@@ -842,6 +871,7 @@ export const ChannelPane = React.memo(function ChannelPane({
               disabled={isComposerDisabled}
               editTarget={threadEditTarget}
               firstUnreadReplyId={threadFirstUnreadReplyId}
+              huddleMemberPubkeys={huddleMemberPubkeys}
               isFollowingThread={isFollowingThread}
               isMessageUnreadById={isMessageUnreadById}
               isSending={isSending}
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 13164cf5eb..c58236312e 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -311,8 +311,15 @@ export function ChannelScreen({
     for (const agent of relayAgents) {
       pubkeys.add(normalizePubkey(agent.pubkey));
     }
+    for (const [pubkey, profile] of Object.entries(
+      messageProfilesQuery.data?.profiles ?? {},
+    )) {
+      if (profile.isAgent) {
+        pubkeys.add(normalizePubkey(pubkey));
+      }
+    }
     return pubkeys;
-  }, [channelMembers, managedAgents, relayAgents]);
+  }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
   const {
     agentSessionCandidates,
     botTypingEntries,
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 30959aa24f..4e5853ba62 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -56,6 +56,7 @@ export const MessageRow = React.memo(
     highlightReplyConnector = false,
     highlightThreadLineDepths,
     hoverBackground = true,
+    huddleMemberPubkeys,
     actionBarPlacement = "floating",
     collapseDescendantsLabel,
     isFollowingThread,
@@ -90,6 +91,7 @@ export const MessageRow = React.memo(
     highlightReplyConnector?: boolean;
     highlightThreadLineDepths?: ReadonlyArray<number>;
     hoverBackground?: boolean;
+    huddleMemberPubkeys?: readonly string[];
     actionBarPlacement?: "floating" | "inside";
     collapseDescendantsLabel?: string;
     isFollowingThread?: boolean;
@@ -157,6 +159,12 @@ export const MessageRow = React.memo(
 
       return pubkeys;
     }, [agentPubkeys, profiles]);
+    const profilePopoverRole =
+      message.role === "bot" ||
+      (message.pubkey &&
+        resolvedAgentPubkeys.has(normalizePubkey(message.pubkey)))
+        ? "bot"
+        : message.role;
     const agentMentionPubkeysByName = React.useMemo(() => {
       if (!mentionPubkeysByName) {
         return undefined;
@@ -284,6 +292,7 @@ export const MessageRow = React.memo(
                 <WaveMessageAttachment
                   channelId={channelId}
                   fallbackText={waveMessage.fallbackText}
+                  huddleMemberPubkeys={huddleMemberPubkeys}
                 />
               );
             }
@@ -640,7 +649,7 @@ export const MessageRow = React.memo(
               {message.pubkey ? (
                 <UserProfilePopover
                   pubkey={message.pubkey}
-                  role={message.role}
+                  role={profilePopoverRole}
                   botIdenticonValue={message.author}
                 >
                   <button
@@ -661,7 +670,7 @@ export const MessageRow = React.memo(
                   {message.pubkey ? (
                     <UserProfilePopover
                       pubkey={message.pubkey}
-                      role={message.role}
+                      role={profilePopoverRole}
                       botIdenticonValue={message.author}
                     >
                       <button
@@ -690,7 +699,7 @@ export const MessageRow = React.memo(
               {message.pubkey ? (
                 <UserProfilePopover
                   pubkey={message.pubkey}
-                  role={message.role}
+                  role={profilePopoverRole}
                   botIdenticonValue={message.author}
                 >
                   <button
@@ -711,7 +720,7 @@ export const MessageRow = React.memo(
                   {message.pubkey ? (
                     <UserProfilePopover
                       pubkey={message.pubkey}
-                      role={message.role}
+                      role={profilePopoverRole}
                       botIdenticonValue={message.author}
                     >
                       <button
@@ -759,6 +768,7 @@ export const MessageRow = React.memo(
     prev.message.tags === next.message.tags &&
     prev.message.role === next.message.role &&
     prev.message.personaDisplayName === next.message.personaDisplayName &&
+    prev.agentPubkeys === next.agentPubkeys &&
     prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
     prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
     prev.connectDescendants === next.connectDescendants &&
@@ -768,6 +778,7 @@ export const MessageRow = React.memo(
     prev.highlightReplyConnector === next.highlightReplyConnector &&
     prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
     prev.hoverBackground === next.hoverBackground &&
+    prev.huddleMemberPubkeys === next.huddleMemberPubkeys &&
     prev.isFollowingThread === next.isFollowingThread &&
     prev.isUnread === next.isUnread &&
     prev.layoutVariant === next.layoutVariant &&
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index b45449e8f0..d1f046a099 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -46,6 +46,7 @@ type MessageThreadPanelProps = {
   currentPubkey?: string;
   disabled?: boolean;
   firstUnreadReplyId?: string | null;
+  huddleMemberPubkeys?: readonly string[];
   layout?: "standalone" | "split";
   editTarget?: {
     author: string;
@@ -346,6 +347,7 @@ export function MessageThreadPanel({
   currentPubkey,
   disabled = false,
   firstUnreadReplyId,
+  huddleMemberPubkeys,
   layout = "standalone",
   editTarget,
   isSending,
@@ -630,6 +632,7 @@ export function MessageThreadPanel({
               actionBarPlacement="inside"
               agentPubkeys={agentPubkeys}
               channelId={channelId}
+              huddleMemberPubkeys={huddleMemberPubkeys}
               isFollowingThread={isFollowingThread}
               isUnread={isMessageUnreadById?.(threadHead.id)}
               layoutVariant="thread-reply"
@@ -748,6 +751,7 @@ export function MessageThreadPanel({
                         }
                         highlightThreadLineDepths={highlightedLineDepths}
                         hoverBackground={!entry.summary}
+                        huddleMemberPubkeys={huddleMemberPubkeys}
                         isUnread={isMessageUnreadById?.(entry.message.id)}
                         layoutVariant="thread-reply"
                         message={entry.message}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index aa8d93d951..63ae084d7a 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -37,6 +37,7 @@ type MessageTimelineProps = {
   channelIntro?: ChannelIntro | null;
   channelName?: string;
   channelType?: ChannelType | null;
+  huddleMemberPubkeys?: readonly string[];
   messages: TimelineMessage[];
   mainEntries?: MainTimelineEntry[];
   directMessageIntro?: {
@@ -154,6 +155,7 @@ const MessageTimelineBase = React.forwardRef<
     hasOlderMessages = true,
     isFetchingOlder = false,
     followThreadById,
+    huddleMemberPubkeys,
     isFollowingThreadById,
     isMessageUnreadById,
     messageFooters,
@@ -702,6 +704,7 @@ const MessageTimelineBase = React.forwardRef<
                     firstUnreadMessageId={firstUnreadMessageId}
                     followThreadById={followThreadById}
                     highlightedMessageId={highlightedMessageId}
+                    huddleMemberPubkeys={huddleMemberPubkeys}
                     isFollowingThreadById={isFollowingThreadById}
                     isMessageUnreadById={isMessageUnreadById}
                     messageFooters={messageFooters}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 79f4db0d46..dfbe680f5a 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -35,6 +35,7 @@ type TimelineMessageListProps = {
   channelName?: string;
   channelType?: ChannelType | null;
   currentPubkey?: string;
+  huddleMemberPubkeys?: readonly string[];
   /** Event id of the oldest unread top-level message; renders a "New" divider above it. */
   firstUnreadMessageId?: string | null;
   followThreadById?: (rootId: string) => void;
@@ -94,6 +95,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   firstUnreadMessageId = null,
   followThreadById,
   highlightedMessageId = null,
+  huddleMemberPubkeys,
   isFollowingThreadById,
   isMessageUnreadById,
   messageFooters,
@@ -206,6 +208,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
               followThreadById={followThreadById}
               footer={messageFooters?.[item.entry.message.id] ?? null}
               highlightedMessageId={highlightedMessageId}
+              huddleMemberPubkeys={huddleMemberPubkeys}
               isFollowingThreadById={isFollowingThreadById}
               isUnread={isMessageUnreadById?.(item.entry.message.id)}
               onDelete={onDelete}
@@ -233,6 +236,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
       currentPubkey,
       followThreadById,
       highlightedMessageId,
+      huddleMemberPubkeys,
       isFollowingThreadById,
       isMessageUnreadById,
       messageFooters,
@@ -318,6 +322,7 @@ type MessageRowItemProps = Pick<
   | "currentPubkey"
   | "followThreadById"
   | "highlightedMessageId"
+  | "huddleMemberPubkeys"
   | "isFollowingThreadById"
   | "onDelete"
   | "onEdit"
@@ -346,6 +351,7 @@ function MessageRowItem({
   followThreadById,
   footer,
   highlightedMessageId,
+  huddleMemberPubkeys,
   isFollowingThreadById,
   isUnread,
   onDelete,
@@ -387,6 +393,7 @@ function MessageRowItem({
           channelId={channelId}
           highlighted={false}
           hoverBackground={false}
+          huddleMemberPubkeys={huddleMemberPubkeys}
           isFollowingThread={
             isFollowingThreadById
               ? isFollowingThreadById(message.id)
@@ -434,6 +441,7 @@ function MessageRowItem({
         agentPubkeys={agentPubkeys}
         channelId={channelId}
         highlighted={message.id === highlightedMessageId || isSearchActive}
+        huddleMemberPubkeys={huddleMemberPubkeys}
         isUnread={isUnread}
         message={message}
         onDelete={canDelete}
diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index cc0ac62b29..090c59b347 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -17,11 +17,13 @@ import {
 type WaveMessageAttachmentProps = {
   channelId?: string | null;
   fallbackText: string;
+  huddleMemberPubkeys?: readonly string[];
 };
 
 export function WaveMessageAttachment({
   channelId,
   fallbackText,
+  huddleMemberPubkeys = [],
 }: WaveMessageAttachmentProps) {
   const queryClient = useQueryClient();
   const { isStarting, startHuddle } = useHuddle();
@@ -36,7 +38,7 @@ export function WaveMessageAttachment({
       }
 
       try {
-        await startHuddle(channelId, []);
+        await startHuddle(channelId, [...huddleMemberPubkeys]);
         await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
       } catch (error) {
         toast.error(
@@ -44,7 +46,7 @@ export function WaveMessageAttachment({
         );
       }
     },
-    [channelId, isStarting, queryClient, startHuddle],
+    [channelId, huddleMemberPubkeys, isStarting, queryClient, startHuddle],
   );
 
   return (
diff --git a/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
index 2842f66a9e..42244eee92 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
+++ b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
@@ -61,6 +61,7 @@ export function ProfileAvatarWithStatus({
   statusTestId,
   testId,
 }: ProfileAvatarWithStatusProps) {
+  const statusLabel = status ? getPresenceLabel(status) : null;
   const cutout = status
     ? {
         cx: geometry.centerX,
@@ -82,12 +83,15 @@ export function ProfileAvatarWithStatus({
       badge={
         status ? (
           <span
-            aria-label={getPresenceLabel(status)}
+            aria-label={statusLabel ?? undefined}
             className="flex h-full w-full items-center justify-center rounded-full"
             data-testid={statusTestId}
             role="img"
           >
             <PresenceDot className="h-full w-full" status={status} />
+            {statusLabel ? (
+              <span className="sr-only">{statusLabel}</span>
+            ) : null}
           </span>
         ) : undefined
       }
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 1d05176d74..ba81fb24cd 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -179,10 +179,10 @@ export function UserProfilePopover({
   const { isStarting: isStartingHuddle, startHuddle } = useHuddle();
   const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
   const relayAgentsQuery = useRelayAgentsQuery({
-    enabled: open && role === "bot",
+    enabled: open,
   });
   const managedAgentsQuery = useManagedAgentsQuery({
-    enabled: open && role === "bot",
+    enabled: open,
   });
   const presenceQuery = usePresenceQuery(open ? [pubkey] : [], {
     enabled: open,
@@ -196,6 +196,7 @@ export function UserProfilePopover({
   const managedAgent = managedAgentsQuery.data?.find(
     (a) => a.pubkey === pubkey,
   );
+  const isBotProfile = role === "bot" || Boolean(relayAgent || managedAgent);
   const profile = profileQuery.data;
   const displayName = profile?.displayName ?? truncatePubkey(pubkey);
   // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
@@ -204,7 +205,7 @@ export function UserProfilePopover({
   // it to every viewer. Combine declared ownership with local management, same
   // shape as the pane/sidebar/memory fixes. Every real boundary is server-side;
   // this only decides whether to paint the "View activity log" button.
-  const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
+  const isOwner = useIsManagedAgent(isBotProfile ? pubkey : null);
   const ownerPubkey = profile?.ownerPubkey ?? null;
   const identityQuery = useIdentityQuery();
   const currentPubkey = identityQuery.data?.pubkey;
@@ -219,14 +220,14 @@ export function UserProfilePopover({
     ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
   const viewerIsOwner = isCurrentUserOwner || isOwner === true;
   const canViewActivity =
-    role === "bot" && viewerIsOwner && Boolean(onOpenAgentSession);
+    isBotProfile && viewerIsOwner && Boolean(onOpenAgentSession);
   const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
   const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
   const userStatusText = userStatus?.text.trim() ?? "";
   const hasUserStatus = Boolean(userStatusText || userStatus?.emoji);
   const profileDescription = profile?.about?.trim() ?? "";
   const profileSubheader = profileDescription || profile?.nip05Handle?.trim();
-  const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
+  const activeTurns = useActiveAgentTurns(isBotProfile ? pubkey : null);
   const channelsQuery = useChannelsQuery();
   const channelIdToName = React.useMemo(() => {
     const map: Record<string, string> = {};
@@ -317,7 +318,7 @@ export function UserProfilePopover({
     try {
       const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
       await goChannel(dm.id);
-      await startHuddle(dm.id, []);
+      await startHuddle(dm.id, isBotProfile ? [pubkey] : []);
       await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
       if (isMountedRef.current) {
         setOpen(false);
@@ -334,6 +335,7 @@ export function UserProfilePopover({
   }, [
     clearHoverTimer,
     goChannel,
+    isBotProfile,
     isStartingHuddle,
     openDmMutation,
     pendingAction,
@@ -499,7 +501,7 @@ export function UserProfilePopover({
             <div className="min-w-0 flex-1">
               <div className="flex items-center gap-1.5">
                 <HoverPubkeyName displayName={displayName} pubkey={pubkey} />
-                {role === "bot" && botIdenticonValue ? (
+                {isBotProfile && botIdenticonValue ? (
                   <BotIdenticon
                     value={botIdenticonValue}
                     size={20}
@@ -518,7 +520,7 @@ export function UserProfilePopover({
             </div>
           </div>
 
-          {role === "bot" && (managedAgent || relayAgent) ? (
+          {isBotProfile && (managedAgent || relayAgent) ? (
             <div className="flex flex-wrap gap-1.5">
               {managedAgent?.agentCommand ? (
                 <InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index c2cb40b92d..05a778eef4 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -61,6 +61,27 @@ function commandCount(commands: string[], command: string) {
   return commands.filter((entry) => entry === command).length;
 }
 
+async function readStartHuddleMemberPubkeys(
+  page: import("@playwright/test").Page,
+) {
+  const commandLog = await readCommandPayloadLog(page);
+  return commandLog.flatMap((entry) => {
+    if (entry.command !== "start_huddle") {
+      return [];
+    }
+
+    const payload =
+      entry.payload && typeof entry.payload === "object"
+        ? (entry.payload as {
+            memberPubkeys?: unknown;
+            member_pubkeys?: unknown;
+          })
+        : null;
+    const memberPubkeys = payload?.memberPubkeys ?? payload?.member_pubkeys;
+    return Array.isArray(memberPubkeys) ? memberPubkeys.map(String) : [];
+  });
+}
+
 async function emitMockMessage(
   page: import("@playwright/test").Page,
   channelName: string,
@@ -1187,6 +1208,33 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({
   await expect(page.getByTestId("user-profile-panel")).toBeVisible();
 });
 
+test("bot profile huddle passes the bot pubkey", async ({ page }) => {
+  await page.goto("/");
+  await page.getByTestId("channel-agents").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("agents");
+
+  const charlieMessage = page
+    .getByTestId("message-row")
+    .filter({ hasText: "Indexing the channel catalog now." })
+    .first();
+  await charlieMessage.locator("button").first().hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await expect(profilePopover.getByText("Codex")).toBeVisible();
+  await profilePopover
+    .getByTestId(
+      `user-profile-popover-huddle-${TEST_IDENTITIES.charlie.pubkey}`,
+    )
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
+});
+
 test("profile popover wave sends a direct message", async ({ page }) => {
   await installMockBridge(page, { sendMessageDelayMs: 2_500 });
 
@@ -1242,3 +1290,34 @@ test("profile popover wave sends a direct message", async ({ page }) => {
     ]),
   );
 });
+
+test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
+  await page.goto("/");
+  await page.getByTestId("channel-agents").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("agents");
+
+  const charlieMessage = page
+    .getByTestId("message-row")
+    .filter({ hasText: "Indexing the channel catalog now." })
+    .first();
+  await charlieMessage.locator("button").first().hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await expect(profilePopover.getByText("Codex")).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-wave-${TEST_IDENTITIES.charlie.pubkey}`)
+    .click();
+
+  await expect(page.getByTestId("message-wave-attachment")).toBeVisible();
+  await page
+    .getByTestId("message-wave-attachment")
+    .getByRole("button", { name: "Start huddle" })
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
+});

From f69ecb71e927e7ebdf0aec445fcfdcd6d0801f5c Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 12:54:05 +0100
Subject: [PATCH 11/15] Pass agent role into inbox profile hovers

---
 desktop/src/features/home/ui/HomeView.tsx     | 18 ++++++
 .../src/features/home/ui/InboxDetailPane.tsx  |  3 +
 .../src/features/home/ui/InboxListPane.tsx    | 15 ++++-
 .../src/features/home/ui/InboxMessageRow.tsx  | 10 +++
 desktop/tests/e2e/smoke.spec.ts               | 62 +++++++++++++++++++
 5 files changed, 107 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx
index 6dabe35e36..8c5b60c254 100644
--- a/desktop/src/features/home/ui/HomeView.tsx
+++ b/desktop/src/features/home/ui/HomeView.tsx
@@ -49,6 +49,7 @@ import type { HomeFeedResponse } from "@/shared/api/types";
 import { KIND_REACTION } from "@/shared/constants/kinds";
 import { topChromeInset } from "@/shared/layout/chromeLayout";
 import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
 import { useElementWidth } from "@/shared/hooks/use-mobile";
 import {
@@ -212,6 +213,21 @@ export function HomeView({
     enabled: feedProfilePubkeys.length > 0,
   });
   const feedProfiles = feedProfilesQuery.data?.profiles;
+  const inboxAgentPubkeys = React.useMemo(() => {
+    const pubkeys = new Set<string>();
+
+    for (const item of feed?.feed.agentActivity ?? []) {
+      pubkeys.add(normalizePubkey(item.pubkey));
+    }
+
+    for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) {
+      if (profile.isAgent) {
+        pubkeys.add(normalizePubkey(pubkey));
+      }
+    }
+
+    return pubkeys;
+  }, [feed?.feed.agentActivity, feedProfiles]);
   const inboxItems = React.useMemo(
     () =>
       buildInboxItems({
@@ -460,6 +476,7 @@ export function HomeView({
         {showListPane ? (
           <InboxListPane
             activeReminderEventIds={activeReminderEventIds}
+            agentPubkeys={inboxAgentPubkeys}
             doneSet={effectiveDoneSet}
             dueReminderCount={dueReminderCount}
             filter={filter}
@@ -527,6 +544,7 @@ export function HomeView({
 
         {showDetailPane ? (
           <InboxDetailPane
+            agentPubkeys={inboxAgentPubkeys}
             canDelete={canDelete}
             canOpenChannel={Boolean(
               selectedItem?.item.channelId &&
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx
index 971a2db285..aa179e711c 100644
--- a/desktop/src/features/home/ui/InboxDetailPane.tsx
+++ b/desktop/src/features/home/ui/InboxDetailPane.tsx
@@ -38,6 +38,7 @@ const MembersSidebar = React.lazy(async () => {
 });
 
 type InboxDetailPaneProps = {
+  agentPubkeys?: ReadonlySet<string>;
   canDelete: boolean;
   canOpenChannel: boolean;
   canReply: boolean;
@@ -69,6 +70,7 @@ type InboxDetailPaneProps = {
 };
 
 export function InboxDetailPane({
+  agentPubkeys,
   canDelete,
   canOpenChannel,
   canReply,
@@ -324,6 +326,7 @@ export function InboxDetailPane({
                   <div className="mx-6 my-3 border-t border-border/60" />
                 ) : null}
                 <InboxMessageRow
+                  agentPubkeys={agentPubkeys}
                   canReply={canReply}
                   channelId={item.item.channelId}
                   isFocusHighlightVisible={isFocusHighlightVisible}
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index d2f4fcdbc3..41b3a0ff02 100644
--- a/desktop/src/features/home/ui/InboxListPane.tsx
+++ b/desktop/src/features/home/ui/InboxListPane.tsx
@@ -17,6 +17,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
 import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel";
 import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
 import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 import {
   ContextMenu,
   ContextMenuContent,
@@ -97,6 +98,7 @@ function ActivityLabel({
 
 type InboxListPaneProps = {
   activeReminderEventIds?: ReadonlySet<string>;
+  agentPubkeys?: ReadonlySet<string>;
   doneSet: ReadonlySet<string>;
   filter: InboxFilter;
   items: InboxItem[];
@@ -116,6 +118,7 @@ type InboxListPaneProps = {
 
 export function InboxListPane({
   activeReminderEventIds,
+  agentPubkeys,
   doneSet,
   filter,
   items,
@@ -154,6 +157,9 @@ export function InboxListPane({
     const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false;
     const hasChannelTarget = Boolean(item.item.channelId);
     const typeLabel = getInboxTypeLabel(item);
+    const isSenderAgent =
+      agentPubkeys?.has(normalizePubkey(item.item.pubkey)) === true;
+    const profileRole = isSenderAgent ? "bot" : undefined;
     const rowHighlightColor = isSelected
       ? "color-mix(in srgb, hsl(var(--background)) 70%, hsl(var(--muted)) 30%)"
       : "color-mix(in srgb, hsl(var(--background)) 75%, hsl(var(--muted)) 25%)";
@@ -188,11 +194,16 @@ export function InboxListPane({
           <div className="relative flex min-w-0 items-start gap-2.5">
             <div className="relative shrink-0">
               <UserProfilePopover
+                botIdenticonValue={item.senderLabel}
                 enableProfilePanel={false}
                 pubkey={item.item.pubkey}
+                role={profileRole}
                 triggerElement="span"
               >
-                <span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
+                <span
+                  className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
+                  data-testid={`home-inbox-item-avatar-${item.id}`}
+                >
                   <UserAvatar
                     avatarUrl={item.avatarUrl}
                     className="h-9 w-9"
@@ -207,8 +218,10 @@ export function InboxListPane({
               <div className="flex min-w-0 items-start gap-2">
                 <span className="min-w-0 flex-1">
                   <UserProfilePopover
+                    botIdenticonValue={item.senderLabel}
                     enableProfilePanel={false}
                     pubkey={item.item.pubkey}
+                    role={profileRole}
                     triggerElement="span"
                   >
                     <span className="block max-w-full truncate rounded text-sm font-semibold leading-4 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx
index 01b03c4015..7bf9a2de76 100644
--- a/desktop/src/features/home/ui/InboxMessageRow.tsx
+++ b/desktop/src/features/home/ui/InboxMessageRow.tsx
@@ -8,6 +8,7 @@ import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
 import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
 import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
 import { cn } from "@/shared/lib/cn";
+import { normalizePubkey } from "@/shared/lib/pubkey";
 import { Markdown } from "@/shared/ui/markdown";
 import { UserAvatar } from "@/shared/ui/UserAvatar";
 
@@ -31,6 +32,7 @@ function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
 }
 
 type InboxMessageRowProps = {
+  agentPubkeys?: ReadonlySet<string>;
   canReply: boolean;
   /** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */
   channelId?: string | null;
@@ -45,6 +47,7 @@ type InboxMessageRowProps = {
 };
 
 export function InboxMessageRow({
+  agentPubkeys,
   canReply,
   channelId = null,
   isFocusHighlightVisible,
@@ -70,6 +73,9 @@ export function InboxMessageRow({
     errorMessage: reactionErrorMessage,
     select: handleReactionSelect,
   } = useReactionHandler(timelineMessage, onToggleReaction);
+  const isAuthorAgent =
+    agentPubkeys?.has(normalizePubkey(message.authorPubkey)) === true;
+  const profileRole = isAuthorAgent ? "bot" : undefined;
 
   return (
     <div className="relative px-5 py-2">
@@ -117,7 +123,9 @@ export function InboxMessageRow({
 
         <div className="relative shrink-0">
           <UserProfilePopover
+            botIdenticonValue={message.authorLabel}
             pubkey={message.authorPubkey}
+            role={profileRole}
             triggerElement="span"
           >
             <span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
@@ -134,7 +142,9 @@ export function InboxMessageRow({
         <div className="min-w-0 flex-1">
           <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
             <UserProfilePopover
+              botIdenticonValue={message.authorLabel}
               pubkey={message.authorPubkey}
+              role={profileRole}
               triggerElement="span"
             >
               <span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts
index 74d66ce65e..ac538c7023 100644
--- a/desktop/tests/e2e/smoke.spec.ts
+++ b/desktop/tests/e2e/smoke.spec.ts
@@ -2,6 +2,9 @@ import { expect, test } from "@playwright/test";
 
 import { installMockBridge } from "../helpers/bridge";
 
+const DEFAULT_AGENT_ACTIVITY_PUBKEY =
+  "db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36";
+
 async function getTimelineMetrics(page: import("@playwright/test").Page) {
   return page.getByTestId("message-timeline").evaluate((element) => {
     const timeline = element as HTMLDivElement;
@@ -80,6 +83,42 @@ async function selectHomeInboxFilter(
   await page.getByRole("menuitemradio", { name: label }).click();
 }
 
+async function readCommandPayloadLog(page: import("@playwright/test").Page) {
+  return page.evaluate(() => {
+    return (
+      (
+        window as Window & {
+          __BUZZ_E2E_COMMAND_LOG__?: Array<{
+            command: string;
+            payload: unknown;
+          }>;
+        }
+      ).__BUZZ_E2E_COMMAND_LOG__ ?? []
+    );
+  });
+}
+
+async function readStartHuddleMemberPubkeys(
+  page: import("@playwright/test").Page,
+) {
+  const commandLog = await readCommandPayloadLog(page);
+  return commandLog.flatMap((entry) => {
+    if (entry.command !== "start_huddle") {
+      return [];
+    }
+
+    const payload =
+      entry.payload && typeof entry.payload === "object"
+        ? (entry.payload as {
+            memberPubkeys?: unknown;
+            member_pubkeys?: unknown;
+          })
+        : null;
+    const memberPubkeys = payload?.memberPubkeys ?? payload?.member_pubkeys;
+    return Array.isArray(memberPubkeys) ? memberPubkeys.map(String) : [];
+  });
+}
+
 test.beforeEach(async ({ page }) => {
   await installMockBridge(page);
 });
@@ -191,6 +230,29 @@ test("inbox feed shows channel and agent activity sections", async ({
   );
 });
 
+test("inbox agent hover huddle passes the agent pubkey", async ({ page }) => {
+  await page.goto("/");
+
+  await selectHomeInboxFilter(page, "Agents");
+  const agentRow = page.getByTestId("home-inbox-item-mock-feed-agent");
+  await expect(agentRow).toContainText(
+    "Agent progress: channel index complete.",
+  );
+
+  await agentRow.getByTestId("home-inbox-item-avatar-mock-feed-agent").hover();
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-huddle-${DEFAULT_AGENT_ACTIVITY_PUBKEY}`)
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([DEFAULT_AGENT_ACTIVITY_PUBKEY]));
+});
+
 test("opens a mocked forum activity item from the inbox feed", async ({
   page,
 }) => {

From d31f3c3408288e301f9180e651c3321d2710e1a7 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 13:13:12 +0100
Subject: [PATCH 12/15] Pass bot hints through mention popovers

---
 .../src/features/home/ui/InboxListPane.tsx    |  2 +-
 .../profile/ui/UserProfilePopover.tsx         | 15 ++++++-
 desktop/src/shared/ui/markdown.tsx            |  7 +++-
 desktop/tests/e2e/mentions.spec.ts            | 39 ++++++++++++++++++-
 desktop/tests/e2e/smoke.spec.ts               |  2 +-
 5 files changed, 59 insertions(+), 6 deletions(-)

diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index 41b3a0ff02..52faa09fdf 100644
--- a/desktop/src/features/home/ui/InboxListPane.tsx
+++ b/desktop/src/features/home/ui/InboxListPane.tsx
@@ -202,7 +202,7 @@ export function InboxListPane({
               >
                 <span
                   className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
-                  data-testid={`home-inbox-item-avatar-${item.id}`}
+                  data-testid={`home-inbox-avatar-${item.id}`}
                 >
                   <UserAvatar
                     avatarUrl={item.avatarUrl}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index ba81fb24cd..be6b1f6c33 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -197,6 +197,10 @@ export function UserProfilePopover({
     (a) => a.pubkey === pubkey,
   );
   const isBotProfile = role === "bot" || Boolean(relayAgent || managedAgent);
+  const isAgentClassificationPending =
+    open &&
+    role !== "bot" &&
+    (relayAgentsQuery.isPending || managedAgentsQuery.isPending);
   const profile = profileQuery.data;
   const displayName = profile?.displayName ?? truncatePubkey(pubkey);
   // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
@@ -308,7 +312,12 @@ export function UserProfilePopover({
   ]);
 
   const handleHuddle = React.useCallback(async () => {
-    if (!showProfileActions || pendingAction !== null || isStartingHuddle) {
+    if (
+      !showProfileActions ||
+      pendingAction !== null ||
+      isStartingHuddle ||
+      isAgentClassificationPending
+    ) {
       return;
     }
 
@@ -335,6 +344,7 @@ export function UserProfilePopover({
   }, [
     clearHoverTimer,
     goChannel,
+    isAgentClassificationPending,
     isBotProfile,
     isStartingHuddle,
     openDmMutation,
@@ -641,7 +651,8 @@ export function UserProfilePopover({
                     disabled={
                       pendingAction !== null ||
                       openDmMutation.isPending ||
-                      isStartingHuddle
+                      isStartingHuddle ||
+                      isAgentClassificationPending
                     }
                     onClick={() => {
                       void handleHuddle();
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 3c9f2bb4b1..6f0aa58ebb 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -2096,7 +2096,12 @@ function createMarkdownComponents(
       }
 
       return pubkey ? (
-        <UserProfilePopover pubkey={pubkey} triggerElement="span">
+        <UserProfilePopover
+          botIdenticonValue={mentionLabel}
+          pubkey={pubkey}
+          role={isAgentMention ? "bot" : undefined}
+          triggerElement="span"
+        >
           {mentionNode}
         </UserProfilePopover>
       ) : (
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 05a778eef4..f052cdf0f8 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -87,17 +87,19 @@ async function emitMockMessage(
   channelName: string,
   content: string,
   options?: {
+    mentionPubkeys?: string[];
     parentEventId?: string;
     pubkey?: string;
   },
 ) {
   const event = await page.evaluate(
-    ({ ch, msg, parentEventId, pubkey }) => {
+    ({ ch, mentionPubkeys, msg, parentEventId, pubkey }) => {
       return (
         window as Window & {
           __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
             channelName: string;
             content: string;
+            mentionPubkeys?: string[];
             parentEventId?: string | null;
             pubkey?: string;
           }) => { id: string; created_at: number; pubkey: string };
@@ -105,12 +107,14 @@ async function emitMockMessage(
       ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
         channelName: ch,
         content: msg,
+        mentionPubkeys,
         parentEventId: parentEventId ?? undefined,
         pubkey: pubkey ?? undefined,
       });
     },
     {
       ch: channelName,
+      mentionPubkeys: options?.mentionPubkeys,
       msg: content,
       parentEventId: options?.parentEventId ?? null,
       pubkey: options?.pubkey ?? TEST_IDENTITIES.alice.pubkey,
@@ -1235,6 +1239,39 @@ test("bot profile huddle passes the bot pubkey", async ({ page }) => {
     .toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
 });
 
+test("agent mention profile huddle passes the bot pubkey", async ({ page }) => {
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  await waitForMockLiveSubscription(page, "general");
+
+  await emitMockMessage(page, "general", "Can @charlie take this?", {
+    mentionPubkeys: [TEST_IDENTITIES.charlie.pubkey],
+  });
+  await waitForTimelineSettled(page);
+
+  const mentionChip = page
+    .getByTestId("message-row")
+    .filter({ hasText: "Can charlie take this?" })
+    .locator("[data-mention].agent-mention-highlight", { hasText: "charlie" });
+  await expect(mentionChip).toBeVisible();
+  await mentionChip.hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(
+      `user-profile-popover-huddle-${TEST_IDENTITIES.charlie.pubkey}`,
+    )
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
+});
+
 test("profile popover wave sends a direct message", async ({ page }) => {
   await installMockBridge(page, { sendMessageDelayMs: 2_500 });
 
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts
index ac538c7023..51efc8d2b8 100644
--- a/desktop/tests/e2e/smoke.spec.ts
+++ b/desktop/tests/e2e/smoke.spec.ts
@@ -239,7 +239,7 @@ test("inbox agent hover huddle passes the agent pubkey", async ({ page }) => {
     "Agent progress: channel index complete.",
   );
 
-  await agentRow.getByTestId("home-inbox-item-avatar-mock-feed-agent").hover();
+  await agentRow.getByTestId("home-inbox-avatar-mock-feed-agent").hover();
   const profilePopover = page.locator(
     '[data-testid="user-profile-popover"][data-state="open"]',
   );

From 89052d4175a35eb1a34b2c455ed25b2b46e5d686 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 13:36:44 +0100
Subject: [PATCH 13/15] Disable wave huddles while bot members resolve

---
 .../features/channels/lib/dmHuddleMembers.ts  | 54 +++++++++++++++++
 .../src/features/channels/ui/ChannelPane.tsx  | 44 +++++---------
 .../features/channels/ui/ChannelScreen.tsx    |  7 +++
 .../src/features/messages/ui/MessageRow.tsx   |  4 ++
 .../messages/ui/MessageThreadPanel.tsx        |  4 ++
 .../features/messages/ui/MessageTimeline.tsx  |  3 +
 .../messages/ui/TimelineMessageList.tsx       |  8 +++
 .../messages/ui/WaveMessageAttachment.tsx     | 16 ++++-
 desktop/src/testing/e2eBridge.ts              | 24 ++++++--
 desktop/tests/e2e/mentions.spec.ts            | 58 +++++++++++++++++++
 desktop/tests/helpers/bridge.ts               |  1 +
 11 files changed, 188 insertions(+), 35 deletions(-)
 create mode 100644 desktop/src/features/channels/lib/dmHuddleMembers.ts

diff --git a/desktop/src/features/channels/lib/dmHuddleMembers.ts b/desktop/src/features/channels/lib/dmHuddleMembers.ts
new file mode 100644
index 0000000000..4da7221202
--- /dev/null
+++ b/desktop/src/features/channels/lib/dmHuddleMembers.ts
@@ -0,0 +1,54 @@
+import type { Channel } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+
+export function getDmHuddleMemberPubkeys(
+  channel: Channel | null,
+  agentPubkeys: ReadonlySet<string> | undefined,
+  currentPubkey: string | undefined,
+) {
+  if (channel?.channelType !== "dm" || !agentPubkeys) {
+    return [];
+  }
+
+  const normalizedCurrentPubkey = currentPubkey
+    ? normalizePubkey(currentPubkey)
+    : null;
+  const seen = new Set<string>();
+
+  return channel.participantPubkeys.filter((pubkey) => {
+    const normalizedPubkey = normalizePubkey(pubkey);
+    if (
+      normalizedCurrentPubkey &&
+      normalizedPubkey === normalizedCurrentPubkey
+    ) {
+      return false;
+    }
+
+    if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) {
+      return false;
+    }
+
+    seen.add(normalizedPubkey);
+    return true;
+  });
+}
+
+export function hasOtherDmParticipant(
+  channel: Channel | null,
+  currentPubkey: string | undefined,
+) {
+  if (channel?.channelType !== "dm") {
+    return false;
+  }
+
+  const normalizedCurrentPubkey = currentPubkey
+    ? normalizePubkey(currentPubkey)
+    : null;
+
+  return channel.participantPubkeys.some((pubkey) => {
+    const normalizedPubkey = normalizePubkey(pubkey);
+    return (
+      !normalizedCurrentPubkey || normalizedPubkey !== normalizedCurrentPubkey
+    );
+  });
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index b0d804c35a..8e32d5c7da 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -13,6 +13,10 @@ import {
 } from "@/features/messages/ui/MessageTimeline";
 import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
 import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
+import {
+  getDmHuddleMemberPubkeys,
+  hasOtherDmParticipant,
+} from "@/features/channels/lib/dmHuddleMembers";
 import {
   buildVideoReviewCommentsByRootId,
   buildVideoReviewContextForMessage,
@@ -65,11 +69,11 @@ import type { Channel } from "@/shared/api/types";
 import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
 import { channelChrome } from "@/shared/layout/chromeLayout";
 import { cn } from "@/shared/lib/cn";
-import { normalizePubkey } from "@/shared/lib/pubkey";
 type ChannelPaneProps = {
   activeChannel: Channel | null;
   activityAgents?: BotActivityAgent[];
   agentPubkeys?: ReadonlySet<string>;
+  agentPubkeysPending?: boolean;
   agentSessionAgents: ChannelAgentSessionAgent[];
   botTypingEntries: TypingIndicatorEntry[];
   channelFind: ReturnType<typeof useChannelFind>;
@@ -181,6 +185,7 @@ type ChannelPaneProps = {
 export const ChannelPane = React.memo(function ChannelPane({
   activeChannel,
   agentPubkeys,
+  agentPubkeysPending = false,
   agentSessionAgents,
   activityAgents = agentSessionAgents,
   botTypingEntries,
@@ -275,33 +280,14 @@ export const ChannelPane = React.memo(function ChannelPane({
     !activeChannel.archivedAt;
   const hasMainComposerOverlay = !isNonMemberView;
   const activeChannelId = activeChannel?.id ?? null;
-  const huddleMemberPubkeys = React.useMemo(() => {
-    if (activeChannel?.channelType !== "dm" || !agentPubkeys) {
-      return [];
-    }
-
-    const normalizedCurrentPubkey = currentPubkey
-      ? normalizePubkey(currentPubkey)
-      : null;
-    const seen = new Set<string>();
-
-    return activeChannel.participantPubkeys.filter((pubkey) => {
-      const normalizedPubkey = normalizePubkey(pubkey);
-      if (
-        normalizedCurrentPubkey &&
-        normalizedPubkey === normalizedCurrentPubkey
-      ) {
-        return false;
-      }
-
-      if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) {
-        return false;
-      }
-
-      seen.add(normalizedPubkey);
-      return true;
-    });
-  }, [activeChannel, agentPubkeys, currentPubkey]);
+  const huddleMemberPubkeys = React.useMemo(
+    () => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey),
+    [activeChannel, agentPubkeys, currentPubkey],
+  );
+  const huddleMemberPubkeysPending =
+    agentPubkeysPending &&
+    huddleMemberPubkeys.length === 0 &&
+    hasOtherDmParticipant(activeChannel, currentPubkey);
   const isActiveWelcomeChannel =
     activeChannel !== null && isWelcomeChannel(activeChannel);
   useComposerHeightPadding(
@@ -706,6 +692,7 @@ export const ChannelPane = React.memo(function ChannelPane({
             hasComposerOverlay={hasMainComposerOverlay}
             hasOlderMessages={hasOlderMessages}
             huddleMemberPubkeys={huddleMemberPubkeys}
+            huddleMemberPubkeysPending={huddleMemberPubkeysPending}
             isFetchingOlder={isFetchingOlder}
             isFollowingThreadById={isFollowingThreadById}
             isMessageUnreadById={isMessageUnreadById}
@@ -872,6 +859,7 @@ export const ChannelPane = React.memo(function ChannelPane({
               editTarget={threadEditTarget}
               firstUnreadReplyId={threadFirstUnreadReplyId}
               huddleMemberPubkeys={huddleMemberPubkeys}
+              huddleMemberPubkeysPending={huddleMemberPubkeysPending}
               isFollowingThread={isFollowingThread}
               isMessageUnreadById={isMessageUnreadById}
               isSending={isSending}
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index c58236312e..ff95563e44 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -320,6 +320,12 @@ export function ChannelScreen({
     }
     return pubkeys;
   }, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
+  const agentPubkeysPending =
+    activeChannel?.channelType === "dm" &&
+    (channelMembersQuery.isPending ||
+      managedAgentsQuery.isPending ||
+      relayAgentsQuery.isPending ||
+      (messageProfilePubkeys.length > 0 && messageProfilesQuery.isPending));
   const {
     agentSessionCandidates,
     botTypingEntries,
@@ -765,6 +771,7 @@ export function ChannelScreen({
                   activeChannel={activeChannel}
                   activityAgents={channelAgentSessionAgents}
                   agentPubkeys={agentPubkeys}
+                  agentPubkeysPending={agentPubkeysPending}
                   agentSessionAgents={agentSessionAgents}
                   botTypingEntries={botTypingEntries}
                   channelFind={channelFind}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 4e5853ba62..f877e64195 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -57,6 +57,7 @@ export const MessageRow = React.memo(
     highlightThreadLineDepths,
     hoverBackground = true,
     huddleMemberPubkeys,
+    huddleMemberPubkeysPending = false,
     actionBarPlacement = "floating",
     collapseDescendantsLabel,
     isFollowingThread,
@@ -92,6 +93,7 @@ export const MessageRow = React.memo(
     highlightThreadLineDepths?: ReadonlyArray<number>;
     hoverBackground?: boolean;
     huddleMemberPubkeys?: readonly string[];
+    huddleMemberPubkeysPending?: boolean;
     actionBarPlacement?: "floating" | "inside";
     collapseDescendantsLabel?: string;
     isFollowingThread?: boolean;
@@ -293,6 +295,7 @@ export const MessageRow = React.memo(
                   channelId={channelId}
                   fallbackText={waveMessage.fallbackText}
                   huddleMemberPubkeys={huddleMemberPubkeys}
+                  huddleMemberPubkeysPending={huddleMemberPubkeysPending}
                 />
               );
             }
@@ -779,6 +782,7 @@ export const MessageRow = React.memo(
     prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
     prev.hoverBackground === next.hoverBackground &&
     prev.huddleMemberPubkeys === next.huddleMemberPubkeys &&
+    prev.huddleMemberPubkeysPending === next.huddleMemberPubkeysPending &&
     prev.isFollowingThread === next.isFollowingThread &&
     prev.isUnread === next.isUnread &&
     prev.layoutVariant === next.layoutVariant &&
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index d1f046a099..6e25393888 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -47,6 +47,7 @@ type MessageThreadPanelProps = {
   disabled?: boolean;
   firstUnreadReplyId?: string | null;
   huddleMemberPubkeys?: readonly string[];
+  huddleMemberPubkeysPending?: boolean;
   layout?: "standalone" | "split";
   editTarget?: {
     author: string;
@@ -348,6 +349,7 @@ export function MessageThreadPanel({
   disabled = false,
   firstUnreadReplyId,
   huddleMemberPubkeys,
+  huddleMemberPubkeysPending = false,
   layout = "standalone",
   editTarget,
   isSending,
@@ -633,6 +635,7 @@ export function MessageThreadPanel({
               agentPubkeys={agentPubkeys}
               channelId={channelId}
               huddleMemberPubkeys={huddleMemberPubkeys}
+              huddleMemberPubkeysPending={huddleMemberPubkeysPending}
               isFollowingThread={isFollowingThread}
               isUnread={isMessageUnreadById?.(threadHead.id)}
               layoutVariant="thread-reply"
@@ -752,6 +755,7 @@ export function MessageThreadPanel({
                         highlightThreadLineDepths={highlightedLineDepths}
                         hoverBackground={!entry.summary}
                         huddleMemberPubkeys={huddleMemberPubkeys}
+                        huddleMemberPubkeysPending={huddleMemberPubkeysPending}
                         isUnread={isMessageUnreadById?.(entry.message.id)}
                         layoutVariant="thread-reply"
                         message={entry.message}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 63ae084d7a..38279eadd5 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -38,6 +38,7 @@ type MessageTimelineProps = {
   channelName?: string;
   channelType?: ChannelType | null;
   huddleMemberPubkeys?: readonly string[];
+  huddleMemberPubkeysPending?: boolean;
   messages: TimelineMessage[];
   mainEntries?: MainTimelineEntry[];
   directMessageIntro?: {
@@ -156,6 +157,7 @@ const MessageTimelineBase = React.forwardRef<
     isFetchingOlder = false,
     followThreadById,
     huddleMemberPubkeys,
+    huddleMemberPubkeysPending = false,
     isFollowingThreadById,
     isMessageUnreadById,
     messageFooters,
@@ -705,6 +707,7 @@ const MessageTimelineBase = React.forwardRef<
                     followThreadById={followThreadById}
                     highlightedMessageId={highlightedMessageId}
                     huddleMemberPubkeys={huddleMemberPubkeys}
+                    huddleMemberPubkeysPending={huddleMemberPubkeysPending}
                     isFollowingThreadById={isFollowingThreadById}
                     isMessageUnreadById={isMessageUnreadById}
                     messageFooters={messageFooters}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index dfbe680f5a..011c8ab10c 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -36,6 +36,7 @@ type TimelineMessageListProps = {
   channelType?: ChannelType | null;
   currentPubkey?: string;
   huddleMemberPubkeys?: readonly string[];
+  huddleMemberPubkeysPending?: boolean;
   /** Event id of the oldest unread top-level message; renders a "New" divider above it. */
   firstUnreadMessageId?: string | null;
   followThreadById?: (rootId: string) => void;
@@ -96,6 +97,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   followThreadById,
   highlightedMessageId = null,
   huddleMemberPubkeys,
+  huddleMemberPubkeysPending = false,
   isFollowingThreadById,
   isMessageUnreadById,
   messageFooters,
@@ -209,6 +211,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
               footer={messageFooters?.[item.entry.message.id] ?? null}
               highlightedMessageId={highlightedMessageId}
               huddleMemberPubkeys={huddleMemberPubkeys}
+              huddleMemberPubkeysPending={huddleMemberPubkeysPending}
               isFollowingThreadById={isFollowingThreadById}
               isUnread={isMessageUnreadById?.(item.entry.message.id)}
               onDelete={onDelete}
@@ -237,6 +240,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
       followThreadById,
       highlightedMessageId,
       huddleMemberPubkeys,
+      huddleMemberPubkeysPending,
       isFollowingThreadById,
       isMessageUnreadById,
       messageFooters,
@@ -323,6 +327,7 @@ type MessageRowItemProps = Pick<
   | "followThreadById"
   | "highlightedMessageId"
   | "huddleMemberPubkeys"
+  | "huddleMemberPubkeysPending"
   | "isFollowingThreadById"
   | "onDelete"
   | "onEdit"
@@ -352,6 +357,7 @@ function MessageRowItem({
   footer,
   highlightedMessageId,
   huddleMemberPubkeys,
+  huddleMemberPubkeysPending,
   isFollowingThreadById,
   isUnread,
   onDelete,
@@ -394,6 +400,7 @@ function MessageRowItem({
           highlighted={false}
           hoverBackground={false}
           huddleMemberPubkeys={huddleMemberPubkeys}
+          huddleMemberPubkeysPending={huddleMemberPubkeysPending}
           isFollowingThread={
             isFollowingThreadById
               ? isFollowingThreadById(message.id)
@@ -442,6 +449,7 @@ function MessageRowItem({
         channelId={channelId}
         highlighted={message.id === highlightedMessageId || isSearchActive}
         huddleMemberPubkeys={huddleMemberPubkeys}
+        huddleMemberPubkeysPending={huddleMemberPubkeysPending}
         isUnread={isUnread}
         message={message}
         onDelete={canDelete}
diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
index 090c59b347..0012d0b140 100644
--- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
+++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx
@@ -18,22 +18,26 @@ type WaveMessageAttachmentProps = {
   channelId?: string | null;
   fallbackText: string;
   huddleMemberPubkeys?: readonly string[];
+  huddleMemberPubkeysPending?: boolean;
 };
 
 export function WaveMessageAttachment({
   channelId,
   fallbackText,
   huddleMemberPubkeys = [],
+  huddleMemberPubkeysPending = false,
 }: WaveMessageAttachmentProps) {
   const queryClient = useQueryClient();
   const { isStarting, startHuddle } = useHuddle();
+  const startHuddleDisabled =
+    !channelId || isStarting || huddleMemberPubkeysPending;
 
   const handleStartHuddle = React.useCallback(
     async (event: React.MouseEvent<HTMLButtonElement>) => {
       event.preventDefault();
       event.stopPropagation();
 
-      if (!channelId || isStarting) {
+      if (startHuddleDisabled) {
         return;
       }
 
@@ -46,7 +50,13 @@ export function WaveMessageAttachment({
         );
       }
     },
-    [channelId, huddleMemberPubkeys, isStarting, queryClient, startHuddle],
+    [
+      channelId,
+      huddleMemberPubkeys,
+      queryClient,
+      startHuddle,
+      startHuddleDisabled,
+    ],
   );
 
   return (
@@ -66,7 +76,7 @@ export function WaveMessageAttachment({
       </AttachmentContent>
       <AttachmentActions>
         <AttachmentAction
-          disabled={!channelId || isStarting}
+          disabled={startHuddleDisabled}
           onClick={handleStartHuddle}
           size="xs"
           type="button"
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 12ad7f13a8..185f006a1c 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -80,6 +80,7 @@ type E2eConfig = {
     };
     managedAgents?: MockManagedAgentSeed[];
     relayAgents?: MockRelayAgentSeed[];
+    agentListDelayMs?: number;
     agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
     createManagedAgentDelayMs?: number;
     channelsReadError?: string;
@@ -4615,7 +4616,19 @@ async function handleGetFeed(
   };
 }
 
-async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
+async function delayAgentList(config: E2eConfig | undefined) {
+  const agentListDelayMs = config?.mock?.agentListDelayMs ?? 0;
+  if (agentListDelayMs > 0) {
+    await new Promise<void>((resolve) => {
+      window.setTimeout(resolve, agentListDelayMs);
+    });
+  }
+}
+
+async function handleListRelayAgents(
+  config: E2eConfig | undefined,
+): Promise<RawRelayAgent[]> {
+  await delayAgentList(config);
   syncMockRelayAgentsFromManagedAgents();
   return mockRelayAgents.map(cloneRelayAgent);
 }
@@ -4742,7 +4755,10 @@ async function handleDiscoverManagedAgentPrereqs(
   };
 }
 
-async function handleListManagedAgents(): Promise<RawManagedAgent[]> {
+async function handleListManagedAgents(
+  config: E2eConfig | undefined,
+): Promise<RawManagedAgent[]> {
+  await delayAgentList(config);
   return mockManagedAgents.map(cloneManagedAgent);
 }
 
@@ -6687,7 +6703,7 @@ export function maybeInstallE2eTauriMocks() {
           activeConfig,
         );
       case "list_relay_agents":
-        return handleListRelayAgents();
+        return handleListRelayAgents(activeConfig);
       case "list_personas":
         return handleListPersonas();
       case "create_persona":
@@ -6796,7 +6812,7 @@ export function maybeInstallE2eTauriMocks() {
       case "export_persona_to_json":
         return handleExportPersonaToJson(payload as { id: string });
       case "list_managed_agents":
-        return handleListManagedAgents();
+        return handleListManagedAgents(activeConfig);
       case "get_agent_memory":
         return handleGetAgentMemory(
           (payload as Parameters<typeof handleGetAgentMemory>[0]) ?? {},
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index f052cdf0f8..672488ff69 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -20,6 +20,8 @@ const REUSABLE_PERSONA_AGENT_PUBKEY =
   "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
 const ALLOWLIST_RELAY_AGENT_PUBKEY =
   "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
+const DELAYED_RELAY_AGENT_PUBKEY =
+  "9999999999999999999999999999999999999999999999999999999999999999";
 const CASEY_PROFILE_PUBKEY =
   "1111111111111111111111111111111111111111111111111111111111111111";
 const PROFILE_ONLY_AGENT_PUBKEY =
@@ -1358,3 +1360,59 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
     .poll(() => readStartHuddleMemberPubkeys(page))
     .toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
 });
+
+test("wave attachment huddle waits for delayed bot DM pubkey", async ({
+  page,
+}) => {
+  await installMockBridge(page, {
+    agentListDelayMs: 5_000,
+    relayAgents: [
+      {
+        pubkey: DELAYED_RELAY_AGENT_PUBKEY,
+        name: "orbit",
+        channelNames: ["general"],
+      },
+    ],
+    searchProfiles: [
+      {
+        pubkey: DELAYED_RELAY_AGENT_PUBKEY,
+        displayName: "orbit",
+      },
+    ],
+  });
+
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  await waitForMockLiveSubscription(page, "general");
+
+  await emitMockMessage(page, "general", "Orbit checking in.", {
+    pubkey: DELAYED_RELAY_AGENT_PUBKEY,
+  });
+  await waitForTimelineSettled(page);
+
+  const orbitMessage = page
+    .getByTestId("message-row")
+    .filter({ hasText: "Orbit checking in." })
+    .first();
+  await orbitMessage.locator("button").first().hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-wave-${DELAYED_RELAY_AGENT_PUBKEY}`)
+    .click();
+
+  const startHuddleButton = page
+    .getByTestId("message-wave-attachment")
+    .getByRole("button", { name: "Start huddle" });
+  await expect(startHuddleButton).toBeDisabled();
+  await expect(startHuddleButton).toBeEnabled({ timeout: 7_000 });
+  await startHuddleButton.click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([DELAYED_RELAY_AGENT_PUBKEY]));
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index e1340e896e..1e4da2e055 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -105,6 +105,7 @@ type MockBridgeOptions = {
   };
   managedAgents?: MockManagedAgentSeed[];
   relayAgents?: MockRelayAgentSeed[];
+  agentListDelayMs?: number;
   createManagedAgentDelayMs?: number;
   channelsReadError?: string;
   feedReadError?: string;

From 81e110df66d4a3088003d1cab324685ea4dff656 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 13:58:31 +0100
Subject: [PATCH 14/15] Pass bot hints from system message profiles

---
 .../features/messages/ui/SystemMessageRow.tsx |  9 +++-
 desktop/tests/e2e/mentions.spec.ts            | 53 +++++++++++++++++++
 2 files changed, 61 insertions(+), 1 deletion(-)

diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx
index 25c10b6739..4be6dd1e8d 100644
--- a/desktop/src/features/messages/ui/SystemMessageRow.tsx
+++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx
@@ -100,8 +100,15 @@ function ProfileName({
     </span>
   );
 
+  const botIdenticonValue = typeof children === "string" ? children : undefined;
+
   return pubkey ? (
-    <UserProfilePopover pubkey={pubkey} triggerElement="span">
+    <UserProfilePopover
+      botIdenticonValue={botIdenticonValue}
+      pubkey={pubkey}
+      role={isAgent ? "bot" : undefined}
+      triggerElement="span"
+    >
       {node}
     </UserProfilePopover>
   ) : (
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 672488ff69..b03af72648 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -914,6 +914,59 @@ test("system add and remove rows use agent mention styling for managed agents",
   ).toHaveText("portal");
 });
 
+test("system agent profile huddle passes profile-only bot pubkey", async ({
+  page,
+}) => {
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
+
+  await page.evaluate(
+    ({ actorPubkey, kind, targetPubkey }) => {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: JSON.stringify({
+          type: "member_joined",
+          actor: actorPubkey,
+          target: targetPubkey,
+        }),
+        kind,
+      });
+    },
+    {
+      actorPubkey: TEST_IDENTITIES.tyler.pubkey,
+      kind: SYSTEM_MESSAGE_KIND,
+      targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
+    },
+  );
+  await waitForTimelineSettled(page);
+
+  const joinedRow = page
+    .getByTestId("system-message-row")
+    .filter({ hasText: "added mira to the channel" });
+  const agentChip = joinedRow.locator(
+    "[data-mention].agent-mention-highlight",
+    {
+      hasText: "mira",
+    },
+  );
+  await expect(agentChip).toHaveText("mira");
+  await agentChip.hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-huddle-${PROFILE_ONLY_AGENT_PUBKEY}`)
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY]));
+});
+
 test("system member-joined rows render the joined person as a mention chip", async ({
   page,
 }) => {

From 7f1a47d3d9a9a978012e0bbda16a33b279f9ebe5 Mon Sep 17 00:00:00 2001
From: klopez4212 <klopez4212@gmail.com>
Date: Mon, 29 Jun 2026 14:34:09 +0100
Subject: [PATCH 15/15] Keep huddle actions pending for bot hints

---
 .../src/features/channels/ui/ChannelPane.tsx  |  4 +-
 .../features/messages/ui/SystemMessageRow.tsx | 60 ++++++++++++++++---
 desktop/tests/e2e/mentions.spec.ts            | 46 ++++++++++++++
 3 files changed, 100 insertions(+), 10 deletions(-)

diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 8e32d5c7da..5296707ac2 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -285,9 +285,7 @@ export const ChannelPane = React.memo(function ChannelPane({
     [activeChannel, agentPubkeys, currentPubkey],
   );
   const huddleMemberPubkeysPending =
-    agentPubkeysPending &&
-    huddleMemberPubkeys.length === 0 &&
-    hasOtherDmParticipant(activeChannel, currentPubkey);
+    agentPubkeysPending && hasOtherDmParticipant(activeChannel, currentPubkey);
   const isActiveWelcomeChannel =
     activeChannel !== null && isWelcomeChannel(activeChannel);
   useComposerHeightPadding(
diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx
index 4be6dd1e8d..df4dafe451 100644
--- a/desktop/src/features/messages/ui/SystemMessageRow.tsx
+++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx
@@ -67,6 +67,24 @@ function resolveDisplayLabel(
   return resolveLabel(pubkey, currentPubkey, profiles);
 }
 
+function isKnownAgentPubkey(
+  pubkey: string | undefined,
+  profiles: UserProfileLookup | undefined,
+  personaLookup?: Map<string, string>,
+  agentPubkeys?: ReadonlySet<string>,
+) {
+  if (!pubkey) {
+    return false;
+  }
+
+  const normalizedPubkey = normalizePubkey(pubkey);
+  return (
+    agentPubkeys?.has(normalizedPubkey) === true ||
+    profiles?.[normalizedPubkey]?.isAgent === true ||
+    personaLookup?.has(normalizedPubkey) === true
+  );
+}
+
 function ProfileName({
   children,
   highlight = false,
@@ -118,12 +136,16 @@ function ProfileName({
 
 function SystemMessageAvatar({
   actorPubkey,
+  agentPubkeys,
   currentPubkey,
+  personaLookup,
   profiles,
   targetPubkey,
 }: {
   actorPubkey: string | undefined;
+  agentPubkeys?: ReadonlySet<string>;
   currentPubkey: string | undefined;
+  personaLookup?: Map<string, string>;
   profiles: UserProfileLookup | undefined;
   targetPubkey: string | undefined;
 }) {
@@ -141,6 +163,12 @@ function SystemMessageAvatar({
   const singlePubkey = actorPubkey ?? targetPubkey;
 
   if (!hasActorAndTarget) {
+    const isSingleAgent = isKnownAgentPubkey(
+      singlePubkey,
+      profiles,
+      personaLookup,
+      agentPubkeys,
+    );
     const avatar = (
       <UserAvatar
         avatarUrl={resolveAvatarUrl(singlePubkey, profiles)}
@@ -152,9 +180,14 @@ function SystemMessageAvatar({
 
     if (singlePubkey) {
       return (
-        <UserProfilePopover pubkey={singlePubkey}>
+        <UserProfilePopover
+          botIdenticonValue={isSingleAgent ? actorLabel : undefined}
+          pubkey={singlePubkey}
+          role={isSingleAgent ? "bot" : undefined}
+        >
           <button
             className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
+            data-testid="system-message-avatar"
             type="button"
           >
             {avatar}
@@ -166,6 +199,12 @@ function SystemMessageAvatar({
     return avatar;
   }
 
+  const isActorAgent = isKnownAgentPubkey(
+    actorPubkey,
+    profiles,
+    personaLookup,
+    agentPubkeys,
+  );
   const targetLabel = resolveUserLabel({
     pubkey: targetPubkey,
     currentPubkey,
@@ -192,7 +231,11 @@ function SystemMessageAvatar({
   );
 
   return (
-    <UserProfilePopover pubkey={actorPubkey}>
+    <UserProfilePopover
+      botIdenticonValue={isActorAgent ? actorLabel : undefined}
+      pubkey={actorPubkey}
+      role={isActorAgent ? "bot" : undefined}
+    >
       <button
         className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
         type="button"
@@ -210,11 +253,12 @@ function describeSystemEvent(
   personaLookup?: Map<string, string>,
   agentPubkeys?: ReadonlySet<string>,
 ): SystemMessageDescription | null {
-  const isTargetAgent =
-    payload.target !== undefined &&
-    (agentPubkeys?.has(normalizePubkey(payload.target)) === true ||
-      profiles?.[normalizePubkey(payload.target)]?.isAgent === true ||
-      personaLookup?.has(normalizePubkey(payload.target)) === true);
+  const isTargetAgent = isKnownAgentPubkey(
+    payload.target,
+    profiles,
+    personaLookup,
+    agentPubkeys,
+  );
   const actorLabel = resolveDisplayLabel(
     payload.actor,
     currentPubkey,
@@ -350,7 +394,9 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
       <div className="flex items-start gap-2.5">
         <SystemMessageAvatar
           actorPubkey={payload.actor}
+          agentPubkeys={agentPubkeys}
           currentPubkey={currentPubkey}
+          personaLookup={personaLookup}
           profiles={profiles}
           targetPubkey={payload.target}
         />
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index b03af72648..16f98d4332 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -967,6 +967,52 @@ test("system agent profile huddle passes profile-only bot pubkey", async ({
     .toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY]));
 });
 
+test("system agent avatar huddle passes profile-only bot pubkey", async ({
+  page,
+}) => {
+  await page.goto("/");
+  await page.getByTestId("channel-general").click();
+  await expect(page.getByTestId("chat-title")).toHaveText("general");
+  await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
+
+  await page.evaluate(
+    ({ kind, targetPubkey }) => {
+      window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+        channelName: "general",
+        content: JSON.stringify({
+          type: "member_joined",
+          actor: targetPubkey,
+          target: targetPubkey,
+        }),
+        kind,
+      });
+    },
+    {
+      kind: SYSTEM_MESSAGE_KIND,
+      targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
+    },
+  );
+  await waitForTimelineSettled(page);
+
+  const joinedRow = page
+    .getByTestId("system-message-row")
+    .filter({ hasText: "mira" })
+    .filter({ hasText: "joined the channel" });
+  await joinedRow.getByTestId("system-message-avatar").hover();
+
+  const profilePopover = page.locator(
+    '[data-testid="user-profile-popover"][data-state="open"]',
+  );
+  await expect(profilePopover).toBeVisible();
+  await profilePopover
+    .getByTestId(`user-profile-popover-huddle-${PROFILE_ONLY_AGENT_PUBKEY}`)
+    .click();
+
+  await expect
+    .poll(() => readStartHuddleMemberPubkeys(page))
+    .toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY]));
+});
+
 test("system member-joined rows render the joined person as a mention chip", async ({
   page,
 }) => {
