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} + + + +
+
+ +

+ {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, }) => {