fix(desktop): refine profile menu interactions

Polish the sidebar profile card, profile popover, and settings entry points so status, workspace, and preferences are easier to discover without layout churn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-06-03 11:23:53 -04:00
co-authored by Cursor
parent 7c896d339d
commit c91fabe54c
11 changed files with 470 additions and 265 deletions
+6 -1
View File
@@ -169,6 +169,9 @@ export function AppShell() {
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
DEFAULT_SETTINGS_SECTION,
);
const [settingsMode, setSettingsMode] = React.useState<
"profile" | "preferences"
>("preferences");
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
@@ -402,8 +405,9 @@ export function AppShell() {
);
const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
(section: SettingsSection = "appearance") => {
setIsChannelManagementOpen(false);
setSettingsMode(section === "profile" ? "profile" : "preferences");
setSettingsSection(section);
setSettingsOpen(true);
},
@@ -845,6 +849,7 @@ export function AppShell() {
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
mode={settingsMode}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
+140 -125
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { ChevronRight, MessageSquare, Settings } from "lucide-react";
import { ChevronRight, Smile } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
@@ -16,7 +16,6 @@ interface ProfilePopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
displayName: string;
nip05?: string | null;
avatarUrl: string | null;
currentStatus: PresenceStatus;
isStatusPending?: boolean;
@@ -25,8 +24,16 @@ interface ProfilePopoverProps {
onSetStatus: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
onOpenSettings: () => void;
onOpenSettings: (section?: "profile" | "appearance") => void;
children: React.ReactNode;
// Optional outer container whose clicks should NOT close the popover.
// Used when auxiliary triggers (avatar, status text) live alongside the
// primary PopoverTrigger and toggle the popover via controlled `open`.
triggerContainerRef?: React.RefObject<HTMLElement | null>;
// Optional slot rendered between the identity block and the menu items.
// Used by the sidebar to surface the workspace/relay selector inside the
// profile menu instead of on the sidebar card.
workspaceSwitcherSlot?: React.ReactNode;
}
// ---------------------------------------------------------------------------
@@ -34,7 +41,7 @@ interface ProfilePopoverProps {
// ---------------------------------------------------------------------------
const MENU_ITEM_CLASS =
"flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-accent cursor-pointer transition-colors";
"flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground hover:bg-accent focus-visible:bg-accent cursor-pointer transition-colors outline-hidden focus:outline-none focus-visible:outline-none";
const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"];
@@ -46,7 +53,6 @@ export function ProfilePopover({
open,
onOpenChange,
displayName,
nip05,
avatarUrl,
currentStatus,
isStatusPending,
@@ -57,10 +63,9 @@ export function ProfilePopover({
onClearUserStatus,
onOpenSettings,
children,
triggerContainerRef,
workspaceSwitcherSlot,
}: ProfilePopoverProps) {
const isMac =
typeof navigator !== "undefined" &&
/Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
const presenceHoverTimer = React.useRef<number | null>(null);
@@ -119,10 +124,19 @@ export function ProfilePopover({
sideOffset={8}
className="w-[280px] rounded-xl border border-border bg-popover p-0 shadow-lg"
data-testid="profile-popover"
onInteractOutside={(event) => {
const target = event.target as Node | null;
if (target && triggerContainerRef?.current?.contains(target)) {
// Click on an auxiliary trigger inside the same card
// (e.g. avatar or status) — let that trigger toggle the
// controlled state instead of auto-closing here.
event.preventDefault();
}
}}
>
<div aria-label="Profile menu" role="menu">
{/* ── Identity block ─────────────────────────────────── */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex items-center gap-3 px-4 pt-3 pb-2">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={avatarUrl}
@@ -135,37 +149,20 @@ export function ProfilePopover({
<p className="truncate text-sm font-semibold text-popover-foreground">
{displayName}
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{nip05 ? <span className="truncate">@{nip05}</span> : null}
{nip05 ? <span aria-hidden="true">·</span> : null}
<span
className="inline-flex items-center gap-1.5"
data-testid="profile-popover-current-status"
>
<PresenceDot status={currentStatus} />
<span>{getPresenceLabel(currentStatus)}</span>
</span>
</div>
{hasUserStatus ? (
<p
className="mt-0.5 truncate text-xs text-muted-foreground"
data-testid="profile-popover-user-status"
>
{userStatusEmoji ? (
<span className="mr-1">{userStatusEmoji}</span>
) : null}
{userStatusText}
</p>
) : null}
<p
className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-muted-foreground"
data-testid="profile-popover-current-status"
>
<PresenceDot className="h-2 w-2" status={currentStatus} />
<span>{getPresenceLabel(currentStatus)}</span>
</p>
</div>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── User status ──────────────────────────────────── */}
<div className="px-1.5 py-1">
{/* ── Status input (Slack-style) ──────────────────────── */}
<div className="px-3 pt-0 pb-1">
<button
className={MENU_ITEM_CLASS}
className="flex w-full items-center gap-2 rounded-lg border border-input bg-background px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none"
data-testid="profile-popover-set-status"
onClick={() => {
closePopover();
@@ -176,102 +173,120 @@ export function ProfilePopover({
role="menuitem"
type="button"
>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-popover-foreground">
{hasUserStatus ? "Update status" : "Set a status"}
</span>
<Smile className="h-4 w-4 shrink-0 text-muted-foreground" />
{hasUserStatus ? (
<span className="flex min-w-0 flex-1 items-center gap-1 truncate text-popover-foreground">
{userStatusEmoji ? (
<span className="shrink-0">{userStatusEmoji}</span>
) : null}
<span className="truncate">{userStatusText}</span>
</span>
) : (
<span className="flex-1 truncate text-muted-foreground">
Update your status
</span>
)}
</button>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Presence status options ───────────────────────── */}
<div className="px-1.5 py-1">
<Popover
onOpenChange={setPresenceMenuOpen}
open={presenceMenuOpen}
>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={MENU_ITEM_CLASS}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => {
clearPresenceHoverTimer();
setPresenceMenuOpen((prev) => !prev);
}}
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
role="menuitem"
type="button"
>
<PresenceDot
className="h-2.5 w-2.5"
status={currentStatus}
/>
<span className="flex-1 text-sm text-popover-foreground">
{getPresenceLabel(currentStatus)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-44 rounded-xl border border-border bg-popover p-1.5 shadow-lg"
{/* ── Presence ────────────────────────────────────────── */}
<Popover onOpenChange={setPresenceMenuOpen} open={presenceMenuOpen}>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={MENU_ITEM_CLASS}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => {
clearPresenceHoverTimer();
setPresenceMenuOpen((prev) => !prev);
}}
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
side="right"
sideOffset={4}
role="menuitem"
type="button"
>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span className="text-sm text-popover-foreground">
{getPresenceLabel(status)}
</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Settings ───────────────────────────────────────── */}
<div className="px-1.5 py-1">
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-settings"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings();
});
}}
role="menuitem"
type="button"
<PresenceDot className="h-2.5 w-2.5" status={currentStatus} />
<span className="flex-1">
{getPresenceLabel(currentStatus)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-44 rounded-xl border border-border bg-popover p-1 shadow-lg"
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
side="right"
sideOffset={4}
>
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm text-popover-foreground">
Settings
</span>
<kbd className="text-xs text-muted-foreground">
{isMac ? "⌘," : "Ctrl+,"}
</kbd>
</button>
</div>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span>{getPresenceLabel(status)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<hr className="mx-3 my-1 h-px border-0 bg-border" />
{/* ── Profile / preferences ──────────────────────────── */}
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-profile"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings("profile");
});
}}
role="menuitem"
type="button"
>
<span className="flex-1">Profile</span>
</button>
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-settings"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings("appearance");
});
}}
role="menuitem"
type="button"
>
<span className="flex-1">Preferences</span>
</button>
{workspaceSwitcherSlot ? (
<>
<hr className="mx-3 my-1 h-px border-0 bg-border" />
{/* ── Workspace / relay selector ─────────────────── */}
<div
className="px-4 py-2"
data-testid="profile-popover-workspace"
>
{workspaceSwitcherSlot}
</div>
</>
) : null}
<div className="h-1" />
</div>
</PopoverContent>
</Popover>
@@ -78,6 +78,11 @@ export type SettingsPanelProps = {
};
export const settingsSections: SettingsSectionDescriptor[] = [
{
value: "appearance",
label: "Appearance",
icon: MonitorCog,
},
{
value: "profile",
label: "Profile",
@@ -103,11 +108,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Compute",
icon: Cpu,
},
{
value: "appearance",
label: "Appearance",
icon: MonitorCog,
},
{
value: "shortcuts",
label: "Shortcuts",
@@ -7,6 +7,7 @@ type SettingsScreenProps = {
currentPubkey?: string;
fallbackDisplayName?: string;
isUpdatingDesktopNotifications: boolean;
mode: "profile" | "preferences";
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
@@ -24,6 +25,7 @@ export function SettingsScreen({
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
mode,
notificationErrorMessage,
notificationPermission,
notificationSettings,
@@ -41,6 +43,7 @@ export function SettingsScreen({
currentPubkey={currentPubkey}
fallbackDisplayName={fallbackDisplayName}
isUpdatingDesktopNotifications={isUpdatingDesktopNotifications}
mode={mode}
notificationErrorMessage={notificationErrorMessage}
notificationPermission={notificationPermission}
notificationSettings={notificationSettings}
@@ -17,6 +17,7 @@ export {
} from "./SettingsPanels";
type SettingsViewProps = SettingsPanelProps & {
mode: "profile" | "preferences";
onClose: () => void;
onSectionChange: (section: SettingsSection) => void;
section: SettingsSection;
@@ -69,6 +70,7 @@ export function SettingsView({
notificationErrorMessage,
notificationPermission,
notificationSettings,
mode,
onClose,
onSectionChange,
onSetDesktopNotificationsEnabled,
@@ -82,6 +84,9 @@ export function SettingsView({
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
return settingsSections.filter((s) => {
if (mode === "preferences" && s.value === "profile") {
return false;
}
if (s.value === "relay-members") {
return (
membership != null &&
@@ -90,7 +95,7 @@ export function SettingsView({
}
return true;
});
}, [myMembershipQuery.data]);
}, [mode, myMembershipQuery.data]);
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState<string | null>(null);
@@ -103,10 +108,19 @@ export function SettingsView({
}, []);
React.useEffect(() => {
if (!visibleSections.some((entry) => entry.value === section)) {
onSectionChange("profile");
if (mode === "profile") {
if (section !== "profile") {
onSectionChange("profile");
}
return;
}
}, [onSectionChange, section, visibleSections]);
if (!visibleSections.some((entry) => entry.value === section)) {
onSectionChange(visibleSections[0]?.value ?? "appearance");
}
}, [mode, onSectionChange, section, visibleSections]);
const showSectionNav = mode === "preferences";
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
@@ -168,35 +182,44 @@ export function SettingsView({
</button>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-cols-[220px_minmax(0,1fr)] md:grid-rows-1">
<aside
className={cn(
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
)}
>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
<div
className={cn(
"grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-rows-1",
showSectionNav
? "md:grid-cols-[220px_minmax(0,1fr)]"
: "md:grid-cols-1",
)}
>
{showSectionNav ? (
<aside
className={cn(
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
)}
>
{visibleSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
isLoaded={isLoaded}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
v{appVersion}
</p>
) : null}
</aside>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
>
{visibleSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
isLoaded={isLoaded}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
v{appVersion}
</p>
) : null}
</aside>
) : null}
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
<div
+19 -81
View File
@@ -15,12 +15,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import type { Workspace } from "@/features/workspaces/types";
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import {
useChannelSections,
type ChannelSection,
@@ -42,6 +37,7 @@ import {
} from "@/features/sidebar/ui/CustomChannelSection";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import type {
Channel,
ChannelVisibility,
@@ -139,7 +135,7 @@ type AppSidebarProps = {
onSelectWorkflows: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
onSelectSettings: () => void;
onSelectSettings: (section?: "profile" | "appearance") => void;
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
@@ -212,7 +208,6 @@ export function AppSidebar({
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
const scrollRef = React.useRef<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
@@ -664,80 +659,23 @@ export function AppSidebar({
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
<SidebarMenu>
<SidebarMenuItem>
<div
className="rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-accent/35 focus-within:bg-sidebar-accent/35 dark:hover:bg-sidebar-accent/25 dark:focus-within:bg-sidebar-accent/25"
data-testid="sidebar-profile-card"
>
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot
className="h-2.5 w-2.5"
status={selfPresenceStatus}
/>
</span>
</div>
<div className="min-w-0 flex-1">
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onSelectSettings}
>
<button
className="block w-full min-w-0 text-left text-sidebar-foreground"
data-testid="open-settings"
type="button"
>
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</button>
</ProfilePopover>
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile"
workspaces={workspaces}
/>
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
) : null}
{selfUserStatus.text}
</p>
) : null}
</div>
</div>
</div>
<SidebarProfileCard
activeWorkspace={activeWorkspace}
isPresencePending={isPresencePending}
onClearUserStatus={onClearUserStatus}
onOpenAddWorkspace={onOpenAddWorkspace}
onOpenSettings={onSelectSettings}
onRemoveWorkspace={onRemoveWorkspace}
onSetPresenceStatus={onSetPresenceStatus}
onSetUserStatus={onSetUserStatus}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
profile={profile}
resolvedDisplayName={resolvedDisplayName}
selfPresenceStatus={selfPresenceStatus}
selfUserStatus={selfUserStatus}
workspaces={workspaces}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
@@ -0,0 +1,175 @@
import * as React from "react";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import type { Workspace } from "@/features/workspaces/types";
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
type SidebarProfileCardProps = {
activeWorkspace: Workspace | null;
isPresencePending?: boolean;
onClearUserStatus: () => void;
onOpenAddWorkspace: () => void;
onOpenSettings: (section?: "profile" | "appearance") => void;
onRemoveWorkspace: (id: string) => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
onSwitchWorkspace: (id: string) => void;
onUpdateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => void;
profile?: Profile;
resolvedDisplayName: string;
selfPresenceStatus: PresenceStatus;
selfUserStatus?: UserStatus;
workspaces: Workspace[];
};
export function SidebarProfileCard({
activeWorkspace,
isPresencePending,
onClearUserStatus,
onOpenAddWorkspace,
onOpenSettings,
onRemoveWorkspace,
onSetPresenceStatus,
onSetUserStatus,
onSwitchWorkspace,
onUpdateWorkspace,
profile,
resolvedDisplayName,
selfPresenceStatus,
selfUserStatus,
workspaces,
}: SidebarProfileCardProps) {
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
const toggleProfilePopover = React.useCallback(
() => setProfilePopoverOpen((prev) => !prev),
[],
);
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
return (
<div
className="group/profile-card rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-border/35 focus-within:bg-sidebar-border/35 dark:hover:bg-sidebar-border/30 dark:focus-within:bg-sidebar-border/30"
data-testid="sidebar-profile-card"
ref={profileCardRef}
>
<div className="flex min-w-0 items-center gap-3">
<button
aria-label={`Open profile menu for ${resolvedDisplayName}`}
className="relative shrink-0 rounded-xl outline-hidden focus:outline-none focus-visible:outline-none"
data-testid="sidebar-profile-avatar-button"
onClick={toggleProfilePopover}
type="button"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-8 w-8 rounded-xl text-xs"
iconClassName="h-4 w-4"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot className="h-2 w-2" status={selfPresenceStatus} />
</span>
</button>
<div className="min-w-0 flex-1">
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onOpenSettings}
triggerContainerRef={profileCardRef}
workspaceSwitcherSlot={
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile-menu"
workspaces={workspaces}
/>
}
>
<button
className="block w-full min-w-0 rounded-sm text-left text-sidebar-foreground outline-hidden focus:outline-none focus-visible:outline-none"
data-testid="open-settings"
type="button"
>
<p
className="truncate text-sm font-semibold leading-tight text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</button>
</ProfilePopover>
{hasStatus ? (
<div className="relative mt-0.5">
<button
aria-label={`Open profile menu for ${resolvedDisplayName}`}
className={cn(
"flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0 group-focus-within/profile-card:opacity-0",
profilePopoverOpen && "opacity-100",
)}
data-testid="sidebar-profile-user-status"
onClick={toggleProfilePopover}
type="button"
>
{selfUserStatus?.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
) : null}
<span className="truncate">{selfUserStatus?.text}</span>
</button>
<div
className={cn(
"pointer-events-none absolute inset-0 flex min-w-0 items-center text-xs leading-none text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100 group-focus-within/profile-card:opacity-100",
profilePopoverOpen && "opacity-0",
)}
>
<span className="truncate">
{activeWorkspace?.name ?? "No workspace"}
</span>
</div>
</div>
) : (
<div className="relative mt-0.5">
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile"
workspaces={workspaces}
/>
</div>
)}
</div>
</div>
</div>
);
}
@@ -166,6 +166,11 @@ export function useSetUserStatusMutation(pubkey?: string) {
? { text, emoji, updatedAt: Math.floor(Date.now() / 1_000) }
: null;
queryClient.setQueryData<UserStatusLookup>(
userStatusQueryKey([normalizedPubkey]),
(old) => ({ ...(old ?? {}), [normalizedPubkey]: status }),
);
queryClient.setQueriesData<UserStatusLookup>(
{ queryKey: ["user-status"] },
(old) => {
@@ -1,6 +1,7 @@
import {
Check,
ChevronDown,
ChevronRight,
MoreHorizontal,
Plus,
WifiOff,
@@ -26,7 +27,6 @@ import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -41,7 +41,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
variant?: "sidebar" | "profile";
variant?: "sidebar" | "profile" | "profile-menu";
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
@@ -66,35 +66,34 @@ export function WorkspaceSwitcher({
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const isProfileLike = variant === "profile" || variant === "profile-menu";
const triggerContent = (
<>
{degraded ? (
{variant === "profile-menu" ? null : degraded ? (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="false"
className={
variant === "profile"
isProfileLike
? "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-destructive"
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
}
data-testid="relay-connection-warning"
role="img"
>
<WifiOff
className={variant === "profile" ? "h-3 w-3" : "h-4 w-4"}
/>
<WifiOff className={isProfileLike ? "h-3 w-3" : "h-4 w-4"} />
</span>
</TooltipTrigger>
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
<TooltipContent side={isProfileLike ? "top" : "bottom"}>
{connectionLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={
variant === "profile"
isProfileLike
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-[10px] leading-none"
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none"
}
@@ -111,27 +110,35 @@ export function WorkspaceSwitcher({
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown
className={
variant === "profile"
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
{variant === "profile-menu" ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown
className={
isProfileLike
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
)}
</>
);
const switcherDropdown = (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
{variant === "profile" ? (
{isProfileLike ? (
<button
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: "Switch workspace"
}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground data-[state=open]:text-sidebar-foreground"
className={
variant === "profile-menu"
? "flex w-full min-w-0 items-center gap-3 py-0.5 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:text-popover-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-popover-foreground"
: "flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 outline-hidden transition-colors hover:text-sidebar-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-sidebar-foreground"
}
data-testid="workspace-switcher"
type="button"
>
@@ -156,7 +163,13 @@ export function WorkspaceSwitcher({
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
onCloseAutoFocus={(e) => e.preventDefault()}
side={variant === "profile" ? "top" : "bottom"}
side={
variant === "profile-menu"
? "right"
: variant === "profile"
? "top"
: "bottom"
}
sideOffset={4}
>
{workspaces.map((workspace) => (
+24
View File
@@ -304,4 +304,28 @@
@apply bg-background text-foreground antialiased;
font-family: "Geist", "Avenir Next", "Segoe UI", sans-serif;
}
/*
* Tailwind v4's preflight no longer sets `cursor: pointer` on buttons.
* Restore it for clearly actionable elements so hover affordance is
* consistent across the app. Disabled / aria-disabled elements keep
* the default cursor; explicit `cursor-*` utility classes still win
* because utilities are in a later layer than base.
*/
button:not(:disabled),
a[href],
summary,
[role="button"]:not([aria-disabled="true"]),
[role="menuitem"]:not([aria-disabled="true"]),
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
[role="menuitemradio"]:not([aria-disabled="true"]),
[role="tab"]:not([aria-disabled="true"]),
[role="link"] {
cursor: pointer;
}
button:disabled,
[aria-disabled="true"] {
cursor: default;
}
}
+6 -2
View File
@@ -21,10 +21,14 @@ export async function openProfileMenu(page: Page) {
export async function openSettings(page: Page, section?: SettingsSection) {
await openProfileMenu(page);
await page.getByTestId("profile-popover-settings").click();
if (section === "profile") {
await page.getByTestId("profile-popover-profile").click();
} else {
await page.getByTestId("profile-popover-settings").click();
}
await expect(page.getByTestId("settings-view")).toBeVisible();
if (section) {
if (section && section !== "profile") {
await page.getByTestId(`settings-nav-${section}`).click();
}
}