feat: user profile panel with hover popover and click-to-expand (#456)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-02 16:21:26 -06:00
committed by GitHub
parent 1487ce6252
commit f946e9b68a
12 changed files with 724 additions and 165 deletions

View File

@@ -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],

View File

@@ -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<void>;
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 ? (
<UserProfilePanel
canResetWidth={canResetThreadPanelWidth}
currentPubkey={currentPubkey}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
pubkey={profilePanelPubkey}
widthPx={threadPanelWidthPx}
/>
) : null}
</div>
);

View File

@@ -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<string | null>(
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 (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
<ChannelScreenHeader
activeChannel={activeChannel}
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle}
activeDmPresenceStatus={activeDmPresenceStatus}
currentPubkey={currentPubkey}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={openChannelManagement}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<ChannelScreenHeader
activeChannel={activeChannel}
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle}
activeDmPresenceStatus={activeDmPresenceStatus}
currentPubkey={currentPubkey}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={openChannelManagement}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
<ForumView
channel={activeChannel}
currentPubkey={currentPubkey}
onClosePost={onCloseForumPost}
onSelectPost={onSelectForumPost}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
</React.Suspense>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
<ForumView
channel={activeChannel}
currentPubkey={currentPubkey}
onClosePost={onCloseForumPost}
onSelectPost={onSelectForumPost}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
</React.Suspense>
) : (
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<ChannelPane
activeChannel={activeChannel}
agentSessionAgents={channelAgentSessionAgents}
botTypingPubkeys={botTypingPubkeys}
channelFind={channelFind}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onCloseAgentSession={handleCloseAgentSession}
onCloseThread={handleCloseThread}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenDm={handleOpenDm}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={
handleThreadScrollTargetResolved
}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
profilePanelPubkey={profilePanelPubkey}
personaLookup={personaLookup}
profiles={messageProfiles}
targetMessageId={targetMessageId}
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
/>
</React.Suspense>
)
) : (
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<ChannelPane
activeChannel={activeChannel}
agentSessionAgents={channelAgentSessionAgents}
botTypingPubkeys={botTypingPubkeys}
channelFind={channelFind}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onCloseAgentSession={handleCloseAgentSession}
onCloseThread={handleCloseThread}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={handleThreadScrollTargetResolved}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
personaLookup={personaLookup}
profiles={messageProfiles}
targetMessageId={targetMessageId}
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
/>
</React.Suspense>
)
) : (
<ChannelScreenEmptyState />
)}
</div>
<ChannelScreenEmptyState />
)}
</div>
<MembersSidebar
channel={activeChannel}
currentPubkey={currentPubkey}
open={isMembersSidebarOpen}
onOpenChange={setIsMembersSidebarOpen}
onViewActivity={handleOpenAgentSession}
/>
<MembersSidebar
channel={activeChannel}
currentPubkey={currentPubkey}
open={isMembersSidebarOpen}
onOpenChange={setIsMembersSidebarOpen}
onViewActivity={handleOpenAgentSession}
/>
</ProfilePanelProvider>
</AgentSessionProvider>
);
}

View File

@@ -12,6 +12,7 @@ type UseChannelAgentSessionsOptions = {
managedAgents: ManagedAgent[];
setExpandedThreadReplyIds: (value: Set<string>) => 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,

View File

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

View File

@@ -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({
}}
>
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={avatarUrl} displayName={authorLabel} size="sm" />
<span className="text-sm font-medium text-foreground">
{authorLabel}
</span>
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */}
<div onClick={(e) => e.stopPropagation()} role="presentation">
<UserProfilePopover pubkey={post.pubkey}>
<button
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
displayName={authorLabel}
size="sm"
/>
<span className="truncate text-sm font-medium text-foreground hover:underline">
{authorLabel}
</span>
</button>
</UserProfilePopover>
</div>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(post.createdAt)}
</span>

View File

@@ -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 (
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
<div className="flex items-center gap-2">
<UserAvatar
avatarUrl={replyAvatarUrl}
displayName={replyAuthorLabel}
size="sm"
/>
<span className="text-sm font-medium text-foreground">
{replyAuthorLabel}
</span>
<UserProfilePopover pubkey={reply.pubkey}>
<button
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
avatarUrl={replyAvatarUrl}
displayName={replyAuthorLabel}
size="sm"
/>
<span className="text-sm font-medium text-foreground hover:underline">
{replyAuthorLabel}
</span>
</button>
</UserProfilePopover>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(reply.createdAt)}
</span>
@@ -208,18 +216,23 @@ export function ForumThreadPanel({
data-forum-event-id={post.eventId}
>
<div className="flex items-center gap-2">
<UserAvatar
avatarUrl={postAvatarUrl}
displayName={postAuthorLabel}
/>
<div>
<span className="text-sm font-semibold text-foreground">
{postAuthorLabel}
</span>
<span className="ml-2 text-xs text-muted-foreground">
{formatRelativeTime(post.createdAt)}
</span>
</div>
<UserProfilePopover pubkey={post.pubkey}>
<button
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<UserAvatar
avatarUrl={postAvatarUrl}
displayName={postAuthorLabel}
/>
<span className="text-sm font-semibold text-foreground hover:underline">
{postAuthorLabel}
</span>
</button>
</UserProfilePopover>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(post.createdAt)}
</span>
{canDeletePost && onDeletePost ? (
<DeleteActionMenu

View File

@@ -133,29 +133,7 @@ export const MessageRow = React.memo(
? "rounded-md"
: "rounded-xl";
const avatarNode = message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className={cn(
"shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
>
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
className={avatarSizeClass}
displayName={message.author}
testId="message-avatar"
/>
</button>
</UserProfilePopover>
) : (
const avatarNode = (
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
@@ -166,18 +144,9 @@ export const MessageRow = React.memo(
);
const authorNode = message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.author}
</button>
</UserProfilePopover>
<span className="truncate text-sm font-semibold tracking-tight hover:underline">
{message.author}
</span>
) : (
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
@@ -296,10 +265,30 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
<div className="flex min-w-0 items-start gap-1.5">
<div className="flex shrink-0 items-start">{avatarNode}</div>
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className="flex shrink-0 items-center gap-1.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{avatarNode}
{authorNode}
</button>
</UserProfilePopover>
) : (
<>
<div className="flex shrink-0 items-start">
{avatarNode}
</div>
{authorNode}
</>
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{authorNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
<span className="text-xs text-muted-foreground">
@@ -316,10 +305,43 @@ export const MessageRow = React.memo(
</>
) : (
<>
<div className="flex shrink-0 items-start">{avatarNode}</div>
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className={cn(
"flex shrink-0 items-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
>
{avatarNode}
</button>
</UserProfilePopover>
) : (
<div className="flex shrink-0 items-start">{avatarNode}</div>
)}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{authorNode}
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={message.role}
botIdenticonValue={message.author}
>
<button
className="truncate rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{authorNode}
</button>
</UserProfilePopover>
) : (
authorNode
)}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
<span className="text-xs text-muted-foreground">

View File

@@ -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<HTMLButtonElement>) => void;
pubkey: string;
widthPx: number;
};
const RUNTIME_LABELS: Record<string, string> = {
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 (
<span className="inline-flex items-center rounded-full bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
{children}
</span>
);
}
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 && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
data-testid="user-profile-panel"
style={{ width: `${widthPx}px` }}
>
{!isOverlay && (
<button
aria-label="Resize profile panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="user-profile-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
</button>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold tracking-tight">Profile</h2>
</div>
<Button
aria-label="Close profile"
data-testid="user-profile-panel-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
<div className="flex flex-col items-center gap-4 pt-4">
{/* Avatar */}
{profile?.avatarUrl ? (
<img
alt={displayName}
className="aspect-square w-full rounded-2xl object-cover shadow-sm"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(profile.avatarUrl)}
/>
) : (
<div className="flex aspect-square w-full items-center justify-center rounded-2xl bg-secondary text-5xl font-semibold text-secondary-foreground shadow-sm">
{displayName.slice(0, 2).toUpperCase()}
</div>
)}
{/* Name + bot identicon */}
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{displayName}</h3>
{isBot ? (
<BotIdenticon
value={displayName}
size={20}
className="shrink-0 rounded"
/>
) : null}
</div>
{profile?.nip05Handle ? (
<p className="text-xs text-muted-foreground">
{profile.nip05Handle}
</p>
) : null}
</div>
{/* Presence */}
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
{/* User status */}
{userStatus ? (
<p className="text-center text-sm text-muted-foreground">
{userStatus.emoji ? (
<span className="mr-1">{userStatus.emoji}</span>
) : null}
{userStatus.text}
</p>
) : null}
</div>
{/* Pubkey (copyable) */}
<div className="mt-6">
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 bg-card/50 px-3 py-2 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted/50"
data-testid="user-profile-copy-pubkey"
onClick={handleCopyPubkey}
title="Copy public key"
type="button"
>
<span className="min-w-0 flex-1 truncate">{pubkey}</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
</button>
</div>
{/* Bot info badges */}
{isBot && (managedAgent || relayAgent) ? (
<div className="mt-4 flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
<InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
) : relayAgent?.agentType ? (
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
) : null}
{managedAgent?.model ? (
<InfoBadge>{managedAgent.model}</InfoBadge>
) : null}
{managedAgent?.acpCommand ? (
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
) : null}
</div>
) : null}
{/* About */}
{profile?.about ? (
<div className="mt-4">
<h4 className="mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
About
</h4>
<p className="text-sm leading-relaxed text-muted-foreground">
{profile.about}
</p>
</div>
) : null}
{/* Actions */}
<div className="mt-6 flex flex-col gap-2">
{onOpenDm && !isSelf ? (
<Button
className="w-full"
data-testid="user-profile-message"
onClick={handleMessage}
type="button"
>
<MessageSquare className="h-4 w-4" />
Message
</Button>
) : 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"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
onClose();
onOpenAgentSession?.(pubkey);
}}
type="button"
>
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
View activity log
</button>
) : null}
</div>
</div>
</aside>
</>
);
}

View File

@@ -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<string, string> = {
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<ReturnType<typeof setTimeout> | 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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="start" className="w-80" side="top" sideOffset={8}>
<PopoverTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: wrapper div for hover/click behavior */}
<div
role="button"
tabIndex={0}
onClick={handleTriggerClick}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && openProfilePanel) {
e.preventDefault();
clearHoverTimer();
setOpen(false);
openProfilePanel(pubkey);
}
}}
onMouseEnter={handleTriggerMouseEnter}
onMouseLeave={handleMouseLeave}
className="inline-flex"
>
{children}
</div>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-80"
onMouseEnter={handleContentMouseEnter}
onMouseLeave={handleMouseLeave}
side="top"
sideOffset={8}
>
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{profile?.avatarUrl ? (

View File

@@ -0,0 +1,32 @@
import * as React from "react";
type ProfilePanelContextValue = {
openProfilePanel: ((pubkey: string) => void) | null;
};
const ProfilePanelContext = React.createContext<ProfilePanelContextValue>({
openProfilePanel: null,
});
export function ProfilePanelProvider({
children,
onOpenProfilePanel,
}: {
children: React.ReactNode;
onOpenProfilePanel: (pubkey: string) => void;
}) {
const value = React.useMemo(
() => ({ openProfilePanel: onOpenProfilePanel }),
[onOpenProfilePanel],
);
return (
<ProfilePanelContext.Provider value={value}>
{children}
</ProfilePanelContext.Provider>
);
}
export function useProfilePanel() {
return React.useContext(ProfilePanelContext);
}

View File

@@ -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();
});