diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 65e9efc1a..39fdb850c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -35,6 +35,7 @@ const overrides = new Map([ ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], + ["src/features/channels/ui/ChannelScreen.tsx", 530], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 656c2e599..a82b78513 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -5,6 +5,7 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { BotActivityBar } from "@/features/channels/ui/BotActivityBar"; @@ -70,6 +71,7 @@ type ChannelPaneProps = { onCancelEdit?: () => void; onCancelThreadReply: () => void; onCloseAgentSession: () => void; + onCloseProfilePanel: () => void; onCloseThread: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -77,6 +79,7 @@ type ChannelPaneProps = { onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string) => void; + onOpenDm?: (pubkeys: string[]) => void; onOpenThread: (message: TimelineMessage) => void; onSelectThreadReplyTarget: (message: TimelineMessage) => void; onSendMessage: ( @@ -101,6 +104,7 @@ type ChannelPaneProps = { profiles?: UserProfileLookup; openThreadHeadId: string | null; openAgentSessionPubkey: string | null; + profilePanelPubkey?: string | null; threadHeadMessage: TimelineMessage | null; threadMessages: MainTimelineEntry[]; threadTypingPubkeys: string[]; @@ -128,6 +132,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit, onCancelThreadReply, onCloseAgentSession, + onCloseProfilePanel, onCloseThread, onDelete, onEdit, @@ -135,6 +140,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandThreadReplies, onJoinChannel, onOpenAgentSession, + onOpenDm, onOpenThread, onSelectThreadReplyTarget, onSendMessage, @@ -146,6 +152,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles, openThreadHeadId, openAgentSessionPubkey, + profilePanelPubkey, targetMessageId, threadHeadMessage, threadMessages, @@ -392,6 +399,17 @@ export const ChannelPane = React.memo(function ChannelPane({ onResizeStart={handleThreadPanelResizeStart} widthPx={threadPanelWidthPx} /> + ) : profilePanelPubkey ? ( + ) : null} ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 45ed6d56d..de35cf856 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -46,7 +46,9 @@ import type { import { useChannelFind } from "@/features/search/useChannelFind"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; +import { useChannelProfilePanel } from "./useChannelProfilePanel"; type ChannelScreenProps = { activeChannel: Channel | null; currentIdentity?: Identity; @@ -71,6 +73,9 @@ export function ChannelScreen({ targetMessageId, }: ChannelScreenProps) { const { markChannelRead, openChannelManagement } = useAppShell(); + const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< + string | null + >(null); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, @@ -341,12 +346,23 @@ export function ChannelScreen({ managedAgents: managedAgentsQuery.data ?? [], setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, timelineMessages, }); + const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = + useChannelProfilePanel({ + closeAgentSession: handleCloseAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + }); + const isTimelineLoading = activeChannel !== null && activeChannel.channelType !== "forum" && @@ -360,6 +376,7 @@ export function ChannelScreen({ setThreadReplyTargetId(null); handleCloseAgentSession(); setEditTargetId(null); + setProfilePanelPubkey(null); }, [handleCloseAgentSession], ); @@ -402,98 +419,105 @@ export function ChannelScreen({ return ( - setIsMembersSidebarOpen((prev) => !prev)} - /> + + setIsMembersSidebarOpen((prev) => !prev)} + /> -
- {activeChannel ? ( - activeChannel.channelType === "forum" ? ( - }> - - +
+ {activeChannel ? ( + activeChannel.channelType === "forum" ? ( + }> + + + ) : ( + }> + + + ) ) : ( - }> - - - ) - ) : ( - - )} -
+ + )} +
- + +
); } diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 5a533d480..80e7ddc92 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -12,6 +12,7 @@ type UseChannelAgentSessionsOptions = { managedAgents: ManagedAgent[]; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; + setProfilePanelPubkey: (value: string | null) => void; setThreadReplyTargetId: (value: string | null) => void; setThreadScrollTargetId: (value: string | null) => void; targetMessageId: string | null; @@ -26,6 +27,7 @@ export function useChannelAgentSessions({ managedAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, @@ -62,11 +64,13 @@ export function useChannelAgentSessions({ setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); + setProfilePanelPubkey(null); setOpenAgentSessionPubkey(pubkey); }, [ setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, ], @@ -79,9 +83,10 @@ export function useChannelAgentSessions({ const openThreadAndCloseAgentSession = React.useCallback( (message: TimelineMessage) => { setOpenAgentSessionPubkey(null); + setProfilePanelPubkey(null); handleOpenThread(message); }, - [handleOpenThread], + [handleOpenThread, setProfilePanelPubkey], ); React.useEffect(() => { @@ -138,6 +143,7 @@ export function useChannelAgentSessions({ } setOpenAgentSessionPubkey(null); + setProfilePanelPubkey(null); setOpenThreadHeadId(threadHeadId); setThreadReplyTargetId(threadHeadId); setThreadScrollTargetId(targetMessageId); @@ -148,6 +154,7 @@ export function useChannelAgentSessions({ activeChannelId, setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts new file mode 100644 index 000000000..e2039713f --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; + +type UseChannelProfilePanelOptions = { + closeAgentSession: () => void; + setExpandedThreadReplyIds: (value: Set) => void; + setOpenThreadHeadId: (value: string | null) => void; + setProfilePanelPubkey: (value: string | null) => void; + setThreadReplyTargetId: (value: string | null) => void; + setThreadScrollTargetId: (value: string | null) => void; +}; + +export function useChannelProfilePanel({ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: UseChannelProfilePanelOptions) { + const { goChannel } = useAppNavigation(); + const openDmMutation = useOpenDmMutation(); + + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + closeAgentSession(); + setProfilePanelPubkey(pubkey); + }, + [ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ], + ); + + const handleCloseProfilePanel = React.useCallback(() => { + setProfilePanelPubkey(null); + }, [setProfilePanelPubkey]); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + + return { + handleOpenProfilePanel, + handleCloseProfilePanel, + handleOpenDm, + }; +} diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 4f6113aaa..eb95aeadf 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -4,6 +4,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -67,10 +68,24 @@ export function ForumPostCard({ }} >
- - - {authorLabel} - + {/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */} +
e.stopPropagation()} role="presentation"> + + + +
{formatRelativeTime(post.createdAt)} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 9c858ec2e..a55141dfb 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -5,6 +5,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -74,14 +75,21 @@ function ReplyRow({ return (
- - - {replyAuthorLabel} - + + + {formatRelativeTime(reply.createdAt)} @@ -208,18 +216,23 @@ export function ForumThreadPanel({ data-forum-event-id={post.eventId} >
- -
- - {postAuthorLabel} - - - {formatRelativeTime(post.createdAt)} - -
+ + + + + {formatRelativeTime(post.createdAt)} + {canDeletePost && onDeletePost ? ( - - - ) : ( + const avatarNode = ( - - + + {message.author} + ) : (

{message.author} @@ -296,10 +265,30 @@ export const MessageRow = React.memo( {isThreadReplyLayout ? ( <>
-
{avatarNode}
+ {message.pubkey ? ( + + + + ) : ( + <> +
+ {avatarNode} +
+ {authorNode} + + )}
- {authorNode} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( @@ -316,10 +305,43 @@ export const MessageRow = React.memo( ) : ( <> -
{avatarNode}
+ {message.pubkey ? ( + + + + ) : ( +
{avatarNode}
+ )}
- {authorNode} + {message.pubkey ? ( + + + + ) : ( + authorNode + )} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx new file mode 100644 index 000000000..afc709f11 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -0,0 +1,282 @@ +import * as React from "react"; +import { Activity, Copy, MessageSquare, X } from "lucide-react"; +import { toast } from "sonner"; + +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { + useRelayAgentsQuery, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { useUserStatusQuery } from "@/features/user-status/hooks"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Button } from "@/shared/ui/button"; +import { + OverlayPanelBackdrop, + PANEL_BASE_CLASS, + PANEL_OVERLAY_CLASS, +} from "@/shared/ui/OverlayPanelBackdrop"; + +type UserProfilePanelProps = { + canResetWidth: boolean; + currentPubkey?: string; + onClose: () => void; + onOpenDm?: (pubkeys: string[]) => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + pubkey: string; + widthPx: number; +}; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} + +function InfoBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function truncatePubkey(pubkey: string) { + if (pubkey.length <= 16) { + return pubkey; + } + + return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; +} + +export function UserProfilePanel({ + canResetWidth, + currentPubkey, + onClose, + onOpenDm, + onResetWidth, + onResizeStart, + pubkey, + widthPx, +}: UserProfilePanelProps) { + const isOverlay = useIsThreadPanelOverlay(); + useEscapeKey(onClose, isOverlay); + + const profileQuery = useUserProfileQuery(pubkey); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const presenceQuery = usePresenceQuery([pubkey]); + const userStatusQuery = useUserStatusQuery([pubkey]); + const { onOpenAgentSession } = useAgentSession(); + + const profile = profileQuery.data; + const pubkeyLower = pubkey.toLowerCase(); + const presenceStatus = presenceQuery.data?.[pubkeyLower]; + const userStatus = userStatusQuery.data?.[pubkeyLower]; + + const relayAgent = relayAgentsQuery.data?.find( + (a) => a.pubkey.toLowerCase() === pubkeyLower, + ); + const managedAgent = managedAgentsQuery.data?.find( + (a) => a.pubkey.toLowerCase() === pubkeyLower, + ); + const isBot = Boolean(relayAgent || managedAgent); + const isSelf = + currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); + const canViewActivity = + isBot && + managedAgent?.backend.type === "local" && + Boolean(onOpenAgentSession); + + const handleCopyPubkey = React.useCallback(() => { + void navigator.clipboard.writeText(pubkey).then(() => { + toast.success("Copied to clipboard"); + }); + }, [pubkey]); + + const handleMessage = React.useCallback(() => { + onOpenDm?.([pubkey]); + onClose(); + }, [onClose, onOpenDm, pubkey]); + + const displayName = profile?.displayName ?? truncatePubkey(pubkey); + + return ( + <> + {isOverlay && } + + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 7167e05df..a2ec0555b 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -11,6 +11,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -24,6 +25,9 @@ type UserProfilePopoverProps = { botIdenticonValue?: string; }; +const HOVER_OPEN_DELAY_MS = 300; +const HOVER_CLOSE_DELAY_MS = 200; + const RUNTIME_LABELS: Record = { goose: "Goose", "claude-code": "Claude Code", @@ -58,6 +62,9 @@ export function UserProfilePopover({ botIdenticonValue, }: UserProfilePopoverProps) { const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, + ); const profileQuery = useUserProfileQuery(open ? pubkey : undefined); const relayAgentsQuery = useRelayAgentsQuery({ enabled: open && role === "bot", @@ -71,6 +78,7 @@ export function UserProfilePopover({ const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); const { onOpenAgentSession } = useAgentSession(); + const { openProfilePanel } = useProfilePanel(); const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, @@ -83,10 +91,78 @@ export function UserProfilePopover({ const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const handleTriggerMouseEnter = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer]); + + const handleMouseLeave = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const handleContentMouseEnter = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + const handleTriggerClick = React.useCallback( + (event: React.MouseEvent) => { + clearHoverTimer(); + if (openProfilePanel) { + event.preventDefault(); + setOpen(false); + openProfilePanel(pubkey); + } + }, + [clearHoverTimer, openProfilePanel, pubkey], + ); + + React.useEffect(() => { + return () => clearHoverTimer(); + }, [clearHoverTimer]); + return ( - {children} - + + {/* biome-ignore lint/a11y/useSemanticElements: wrapper div for hover/click behavior */} +
{ + if ((e.key === "Enter" || e.key === " ") && openProfilePanel) { + e.preventDefault(); + clearHoverTimer(); + setOpen(false); + openProfilePanel(pubkey); + } + }} + onMouseEnter={handleTriggerMouseEnter} + onMouseLeave={handleMouseLeave} + className="inline-flex" + > + {children} +
+
+
{profile?.avatarUrl ? ( diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx new file mode 100644 index 000000000..c62af561a --- /dev/null +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +type ProfilePanelContextValue = { + openProfilePanel: ((pubkey: string) => void) | null; +}; + +const ProfilePanelContext = React.createContext({ + openProfilePanel: null, +}); + +export function ProfilePanelProvider({ + children, + onOpenProfilePanel, +}: { + children: React.ReactNode; + onOpenProfilePanel: (pubkey: string) => void; +}) { + const value = React.useMemo( + () => ({ openProfilePanel: onOpenProfilePanel }), + [onOpenProfilePanel], + ); + + return ( + + {children} + + ); +} + +export function useProfilePanel() { + return React.useContext(ProfilePanelContext); +} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 04f9d5f78..2aac35e52 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -127,7 +127,7 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionSpan).toBeVisible(); }); -test("clicking author name opens user profile popover", async ({ page }) => { +test("clicking author name opens user profile panel", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -139,25 +139,32 @@ test("clicking author name opens user profile popover", async ({ page }) => { }); await authorButton.click(); - const popover = page.locator("[data-radix-popper-content-wrapper]"); - await expect(popover).toBeVisible(); - await expect(popover).toContainText("deadbeef"); - // Notes section removed — user status replaces it. - // Verify the popover is still functional (pubkey visible confirms data loaded). - await expect(popover).toContainText("deadbeef"); + // Click now opens the full profile panel instead of the popover + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("deadbeef"); }); -test("clicking avatar opens user profile popover", async ({ page }) => { +test("hovering avatar opens popover, clicking opens profile panel", async ({ + page, +}) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // Click the avatar button on the first message const firstMessage = page.getByTestId("message-row").first(); const avatarButton = firstMessage.locator("button").first(); - await avatarButton.click(); + // Hover should open the popover + await avatarButton.hover(); await expect( page.locator("[data-radix-popper-content-wrapper]"), ).toBeVisible(); + + // Click should close the popover and open the profile panel + await avatarButton.click(); + await expect( + page.locator("[data-radix-popper-content-wrapper]"), + ).not.toBeVisible(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); });