diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
index 358a0e637b..ff484b6fb1 100644
--- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
@@ -8,6 +8,7 @@ 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 { BestieChatPopover } from "@/features/messages/ui/BestieChatPopover";
import {
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
ProfileAvatarWithStatus,
@@ -118,6 +119,7 @@ export function ChannelScreenHeader({
) : null;
const actions = activeChannel ? (
+
{terminalButton}
{channelActions}
diff --git a/desktop/src/features/messages/ui/BestieChatPopover.tsx b/desktop/src/features/messages/ui/BestieChatPopover.tsx
new file mode 100644
index 0000000000..a4477ed5b6
--- /dev/null
+++ b/desktop/src/features/messages/ui/BestieChatPopover.tsx
@@ -0,0 +1,260 @@
+import { Loader2 } from "lucide-react";
+import * as React from "react";
+import { toast } from "sonner";
+
+import { useManagedAgentsQuery } from "@/features/agents/hooks";
+import { pickBestieAgent } from "@/features/agents/lib/bestie";
+import { useOpenDmMutation } from "@/features/channels/hooks";
+import { useCommunities } from "@/features/communities/useCommunities";
+import {
+ useChannelMessagesQuery,
+ useChannelSubscription,
+ useSendMessageMutation,
+} from "@/features/messages/hooks";
+import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
+import { MessageComposer } from "@/features/messages/ui/MessageComposer";
+import { MessageThreadTranscript } from "@/features/messages/ui/MessageThreadTranscript";
+import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import { useProfileQuery } from "@/features/profile/hooks";
+import { useIdentityQuery } from "@/shared/api/hooks";
+import type { Channel } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { Button } from "@/shared/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+
+export function BestieChatPopover() {
+ const { activeCommunity } = useCommunities();
+ const identityQuery = useIdentityQuery();
+ const profileQuery = useProfileQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const openDmMutation = useOpenDmMutation();
+ const sendMessageMutation = useSendMessageMutation(null, identityQuery.data);
+ const [open, setOpen] = React.useState(false);
+ const [channel, setChannel] = React.useState(null);
+ const scrollRef = React.useRef(null);
+ const openRequestRef = React.useRef(0);
+ const hasPositionedScrollRef = React.useRef(false);
+ const shouldStickToBottomRef = React.useRef(true);
+ const bestie = React.useMemo(
+ () =>
+ pickBestieAgent(managedAgentsQuery.data ?? [], activeCommunity?.relayUrl),
+ [activeCommunity?.relayUrl, managedAgentsQuery.data],
+ );
+ const conversationScope = `${activeCommunity?.relayUrl ?? ""}:${bestie?.pubkey ?? ""}`;
+ const conversationScopeRef = React.useRef(conversationScope);
+
+ useChannelSubscription(channel);
+ const messagesQuery = useChannelMessagesQuery(channel);
+ const currentPubkey = identityQuery.data?.pubkey ?? null;
+ const profiles = React.useMemo(() => {
+ if (!bestie) return undefined;
+ return {
+ [normalizePubkey(bestie.pubkey)]: {
+ avatarUrl: bestie.avatarUrl,
+ displayName: bestie.name,
+ isAgent: true,
+ name: bestie.name,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ ...(currentPubkey
+ ? {
+ [normalizePubkey(currentPubkey)]: {
+ avatarUrl: profileQuery.data?.avatarUrl ?? null,
+ displayName: "You",
+ isAgent: false,
+ name: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ }
+ : {}),
+ };
+ }, [bestie, currentPubkey, profileQuery.data?.avatarUrl]);
+ const messages = React.useMemo(
+ () =>
+ channel
+ ? formatTimelineMessages(
+ messagesQuery.data ?? [],
+ channel,
+ currentPubkey ?? undefined,
+ profileQuery.data?.avatarUrl ?? null,
+ profiles,
+ )
+ : [],
+ [
+ channel,
+ currentPubkey,
+ messagesQuery.data,
+ profileQuery.data?.avatarUrl,
+ profiles,
+ ],
+ );
+ const lastMessageId = messages.at(-1)?.id ?? null;
+
+ React.useEffect(() => {
+ if (!lastMessageId) return;
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) return;
+ if (!hasPositionedScrollRef.current || shouldStickToBottomRef.current) {
+ scrollElement.scrollTo({
+ behavior: hasPositionedScrollRef.current ? "smooth" : "auto",
+ top: scrollElement.scrollHeight,
+ });
+ }
+ hasPositionedScrollRef.current = true;
+ }, [lastMessageId]);
+
+ React.useEffect(() => {
+ if (conversationScopeRef.current === conversationScope) return;
+ conversationScopeRef.current = conversationScope;
+ setOpen(false);
+ setChannel(null);
+ }, [conversationScope]);
+
+ if (!bestie) return null;
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ hasPositionedScrollRef.current = false;
+ shouldStickToBottomRef.current = true;
+ const requestId = ++openRequestRef.current;
+ if (!nextOpen) {
+ setChannel(null);
+ return;
+ }
+
+ void openDmMutation
+ .mutateAsync({
+ pubkeys: [bestie.pubkey],
+ expectedRelayUrl: activeCommunity?.relayUrl,
+ expectedSignerPubkey: currentPubkey ?? undefined,
+ })
+ .then((openedChannel) => {
+ if (openRequestRef.current === requestId) {
+ setChannel(openedChannel);
+ }
+ })
+ .catch((error) => {
+ if (openRequestRef.current !== requestId) return;
+ console.error("Failed to open Bestie conversation", error);
+ toast.error(`Couldn't open ${bestie.name}`);
+ });
+ };
+
+ const submit = async (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ ) => {
+ if (!channel) return;
+ await sendMessageMutation.mutateAsync({
+ content,
+ mediaTags,
+ mentionPubkeys,
+ targetChannel: channel,
+ });
+ };
+
+ const isLoading =
+ openDmMutation.isPending || (channel && messagesQuery.isLoading);
+ const isSending = sendMessageMutation.isPending;
+
+ return (
+
+
+
+
+
+
+
+ Message {bestie.name}
+
+
+
+
+
+
+
{
+ const element = event.currentTarget;
+ shouldStickToBottomRef.current =
+ element.scrollHeight -
+ element.scrollTop -
+ element.clientHeight <
+ 64;
+ }}
+ ref={scrollRef}
+ >
+ {isLoading ? (
+
+
+
+ ) : messages.length > 0 && channel ? (
+
+ ) : (
+
+
+ Your messages with {bestie.name} will show up here.
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/tests/e2e/bestie.spec.ts b/desktop/tests/e2e/bestie.spec.ts
index 408df770aa..931143ec24 100644
--- a/desktop/tests/e2e/bestie.spec.ts
+++ b/desktop/tests/e2e/bestie.spec.ts
@@ -40,6 +40,63 @@ test("Bestie sidebar shortcut always opens its direct message", async ({
await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
});
+test("Bestie header avatar opens an inline conversation and sends messages", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 1000, height: 760 });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
+ const trigger = page.getByTestId("open-bestie-panel");
+ await expect(trigger).toBeVisible();
+ await expect(trigger).toHaveAccessibleName("Open Bestie chat");
+ await trigger.click();
+
+ const popover = page.getByTestId("bestie-chat-popover");
+ await expect(popover).toBeVisible();
+ await expect(popover).toContainText("Bestie");
+ const composer = popover.getByTestId("message-composer");
+ await expect(composer).toBeVisible();
+ await expect(composer.getByRole("button", { name: "Send" })).toBeDisabled();
+ const editor = composer.locator('[contenteditable="true"]');
+ await expect(editor).toBeEditable();
+ for (let index = 0; index < 7; index += 1) {
+ const content = `History note ${index + 1}: This is a longer Bestie conversation entry for the scrollable floating panel.`;
+ await editor.fill(content);
+ await composer.getByRole("button", { name: "Send" }).click();
+ await expect(popover.getByTestId("bestie-chat-transcript")).toContainText(
+ content,
+ );
+ }
+ const scroll = popover.getByTestId("bestie-chat-scroll");
+ await expect
+ .poll(() =>
+ scroll.evaluate((element) => element.scrollHeight > element.clientHeight),
+ )
+ .toBe(true);
+ await scroll.evaluate((element) => {
+ element.scrollTop = 0;
+ });
+ await expect
+ .poll(() => scroll.evaluate((element) => element.scrollTop))
+ .toBe(0);
+ if (process.env.BUZZ_BESTIE_CHAT_SCREENSHOT) {
+ await page.screenshot({
+ animations: "disabled",
+ path: process.env.BUZZ_BESTIE_CHAT_SCREENSHOT,
+ });
+ }
+ await editor.fill("Can you help me prioritize this?");
+ await expect(composer.getByRole("button", { name: "Send" })).toBeEnabled();
+ await composer.getByRole("button", { name: "Send" }).click();
+
+ await expect(popover.getByTestId("bestie-chat-transcript")).toContainText(
+ "Can you help me prioritize this?",
+ );
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+});
+
test("message action sends a message link and optional note to Bestie", async ({
page,
}) => {