feat(desktop): add floating Bestie chat

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-26 09:58:02 -04:00
parent 8a8688e820
commit bc5f9c85ce
3 changed files with 319 additions and 0 deletions

View File

@@ -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 ? (
<div className="flex items-center gap-1">
<BestieChatPopover />
{terminalButton}
{channelActions}
</div>

View File

@@ -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<Channel | null>(null);
const scrollRef = React.useRef<HTMLDivElement>(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 (
<Popover onOpenChange={handleOpenChange} open={open}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label={`Open ${bestie.name} chat`}
className="h-9 w-9 rounded-full p-0"
data-testid="open-bestie-panel"
size="icon"
type="button"
variant="ghost"
>
<ProfileAvatar
avatarUrl={bestie.avatarUrl}
className="size-7 text-[10px]"
label={bestie.name}
plain
testId="bestie-header-avatar"
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Message {bestie.name}</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="h-[min(480px,calc(100vh-6rem))] w-[min(360px,calc(100vw-2rem))] overflow-hidden p-0"
data-testid="bestie-chat-popover"
side="bottom"
sideOffset={8}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-2.5 border-b border-border/60 px-3.5 py-3">
<ProfileAvatar
avatarUrl={bestie.avatarUrl}
className="size-8 text-xs"
label={bestie.name}
/>
<p className="min-w-0 truncate text-sm font-semibold">
{bestie.name}
</p>
</div>
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto py-3"
data-testid="bestie-chat-scroll"
onScroll={(event) => {
const element = event.currentTarget;
shouldStickToBottomRef.current =
element.scrollHeight -
element.scrollTop -
element.clientHeight <
64;
}}
ref={scrollRef}
>
{isLoading ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : messages.length > 0 && channel ? (
<MessageThreadTranscript
channelId={channel.id}
currentPubkey={currentPubkey ?? undefined}
messages={messages}
profiles={profiles}
testId="bestie-chat-transcript"
/>
) : (
<div className="flex h-full items-center justify-center px-8 text-center">
<p className="text-xs text-muted-foreground">
Your messages with {bestie.name} will show up here.
</p>
</div>
)}
</div>
<MessageComposer
channelId={channel?.id ?? null}
channelName={bestie.name}
channelType="dm"
containerClassName="shrink-0 border-t border-border/60 px-3 pb-3 pt-2"
disabled={!channel || isSending}
draftKey={`bestie-panel:${channel?.id ?? "loading"}`}
isSending={isSending}
onSend={submit}
placeholder={`Message ${bestie.name}`}
profiles={profiles}
showBackgroundUploadProgress={false}
/>
</div>
</PopoverContent>
</Popover>
);
}

View File

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