Move workspace switcher to sidebar header

This commit is contained in:
klopez4212
2026-06-23 19:24:45 +01:00
parent 40070a5855
commit dcf8833a45
4 changed files with 117 additions and 105 deletions
@@ -33,10 +33,6 @@ interface ProfilePopoverProps {
// 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;
}
// ---------------------------------------------------------------------------
@@ -68,7 +64,6 @@ export function ProfilePopover({
onOpenSettings,
children,
triggerContainerRef,
workspaceSwitcherSlot,
}: ProfilePopoverProps) {
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
@@ -250,8 +245,6 @@ export function ProfilePopover({
</PopoverContent>
</Popover>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Settings ───────────────────────────────────────── */}
<button
className={MENU_ITEM_CLASS}
@@ -270,16 +263,6 @@ export function ProfilePopover({
{settingsShortcutLabel}
</kbd>
</button>
{workspaceSwitcherSlot ? (
<>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Workspace / relay selector ─────────────────── */}
<div data-testid="profile-popover-workspace">
{workspaceSwitcherSlot}
</div>
</>
) : null}
</div>
</PopoverContent>
</Popover>
+12 -6
View File
@@ -15,6 +15,7 @@ import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
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 {
useChannelSections,
@@ -529,6 +530,17 @@ export function AppSidebar({
className="mt-(--buzz-top-chrome-height,2.5rem) shrink-0 px-2 pt-2"
data-testid="sidebar-pinned-header"
>
<div className="mb-2.5 group-data-[collapsible=icon]:hidden">
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="sidebar-card"
workspaces={workspaces}
/>
</div>
<TopbarSearch
channels={searchChannels}
currentPubkey={currentPubkey}
@@ -860,21 +872,15 @@ export function AppSidebar({
<SidebarMenu>
<SidebarMenuItem>
<SidebarProfileCard
activeWorkspace={activeWorkspace}
isPresencePending={isPresencePending}
onOpenAddWorkspace={onOpenAddWorkspace}
onOpenSettings={onSelectSettings}
onRemoveWorkspace={onRemoveWorkspace}
onSetPresenceStatus={onSetPresenceStatus}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
profile={profile}
resolvedDisplayName={resolvedDisplayName}
selfPresenceStatus={selfPresenceStatus}
selfUserStatus={selfUserStatus}
workspaces={workspaces}
/>
</SidebarMenuItem>
</SidebarMenu>
@@ -6,48 +6,30 @@ import { useSelfProfileCache } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
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;
onOpenAddWorkspace: () => void;
onOpenSettings: (section?: "profile" | "appearance") => void;
onRemoveWorkspace: (id: string) => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => 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,
onOpenAddWorkspace,
onOpenSettings,
onRemoveWorkspace,
onSetPresenceStatus,
onSetUserStatus,
onClearUserStatus,
onSwitchWorkspace,
onUpdateWorkspace,
profile,
resolvedDisplayName,
selfPresenceStatus,
selfUserStatus,
workspaces,
}: SidebarProfileCardProps) {
const selfProfileCache = useSelfProfileCache();
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
@@ -70,18 +52,6 @@ export function SidebarProfileCard({
[toggleProfilePopover],
);
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
const workspaceLabel = activeWorkspace?.name ?? "No workspace";
const readonlyWorkspaceLabel = (
<span className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70">
<span
aria-hidden="true"
className="flex w-3.5 shrink-0 items-center justify-center text-2xs"
>
<span className="-translate-y-px leading-normal">🐝</span>
</span>
<span className="truncate">{workspaceLabel}</span>
</span>
);
return (
// biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: child buttons provide keyboard access; wrapper fills pointer gaps between them.
@@ -136,17 +106,6 @@ export function SidebarProfileCard({
triggerContainerRef={profileCardRef}
userStatusEmoji={selfUserStatus?.emoji}
userStatusText={selfUserStatus?.text}
workspaceSwitcherSlot={
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile-menu"
workspaces={workspaces}
/>
}
>
<button
onClick={(event) => {
@@ -167,40 +126,25 @@ export function SidebarProfileCard({
</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 leading-snug text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0",
profilePopoverOpen && "opacity-100",
)}
data-testid="sidebar-profile-user-status"
onClick={(event) => {
event.stopPropagation();
toggleProfilePopover();
}}
type="button"
>
{selfUserStatus?.emoji ? (
<StatusEmoji
className="mr-1 w-4 shrink-0 text-xs"
value={selfUserStatus.emoji}
/>
) : 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-snug text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100",
profilePopoverOpen && "opacity-0",
)}
>
{readonlyWorkspaceLabel}
</div>
</div>
) : (
<div className="relative mt-0.5">{readonlyWorkspaceLabel}</div>
)}
<button
aria-label={`Open profile menu for ${resolvedDisplayName}`}
className="mt-0.5 flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs leading-snug text-sidebar-foreground/70 outline-hidden focus:outline-none focus-visible:outline-none"
data-testid="sidebar-profile-user-status"
onClick={(event) => {
event.stopPropagation();
toggleProfilePopover();
}}
type="button"
>
{selfUserStatus?.emoji ? (
<StatusEmoji
className="mr-1 w-4 shrink-0 text-xs"
value={selfUserStatus.emoji}
/>
) : null}
<span className="truncate">{selfUserStatus?.text}</span>
</button>
) : null}
</div>
</div>
</div>
@@ -28,6 +28,7 @@ import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { cn } from "@/shared/lib/cn";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -42,7 +43,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
variant?: "sidebar" | "profile" | "profile-menu";
variant?: "sidebar" | "sidebar-card" | "profile" | "profile-menu";
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
@@ -52,10 +53,43 @@ type WorkspaceSwitcherProps = {
onRemoveWorkspace: (id: string) => void;
};
function WorkspaceEmojiIcon({ className }: { className: string }) {
function getWorkspaceInitial(name: string): string {
return name.trim().charAt(0).toUpperCase() || "W";
}
function WorkspaceAvatar({
className,
emoji,
imageUrl,
name,
}: {
className: string;
emoji?: string;
imageUrl?: string | null;
name: string;
}) {
return (
<span aria-hidden="true" className={className}>
<span className="-translate-y-px leading-normal">🐝</span>
<span
aria-hidden="true"
className={cn(
"overflow-hidden rounded-xl bg-sidebar-accent/40 text-sidebar-foreground/80",
className,
)}
>
{imageUrl ? (
<img
alt=""
className="h-full w-full object-cover"
draggable={false}
src={imageUrl}
/>
) : emoji ? (
<span className="-translate-y-px leading-normal">{emoji}</span>
) : (
<span className="font-medium leading-none">
{getWorkspaceInitial(name)}
</span>
)}
</span>
);
}
@@ -77,6 +111,8 @@ export function WorkspaceSwitcher({
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const isProfileVariant = variant === "profile";
const isSidebarCardVariant = variant === "sidebar-card";
const workspaceName = activeWorkspace?.name ?? "No workspace";
function clearProfileMenuHoverTimer() {
if (profileMenuHoverTimer.current !== null) {
@@ -137,12 +173,14 @@ export function WorkspaceSwitcher({
</TooltipContent>
</Tooltip>
) : (
<WorkspaceEmojiIcon
<WorkspaceAvatar
className={
isProfileVariant
? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
: "flex w-5 shrink-0 items-center justify-center text-xs"
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-2xs"
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs"
}
emoji="🐝"
name={workspaceName}
/>
)}
<span
@@ -270,6 +308,45 @@ export function WorkspaceSwitcher({
>
{triggerContent}
</button>
) : isSidebarCardVariant ? (
<button
aria-label={
degraded
? `${workspaceName}${connectionLabel}`
: `Switch workspace: ${workspaceName}`
}
className="group/workspace-card inline-flex max-w-full min-w-0 items-center gap-0.5 rounded-xl px-2 py-1.5 text-left text-sidebar-foreground outline-hidden transition-colors hover:bg-sidebar-border/35 focus:outline-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring dark:hover:bg-sidebar-border/30"
data-testid="sidebar-workspace-card"
type="button"
>
{degraded ? (
<span
aria-hidden="true"
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
>
<WifiOff className="h-3.5 w-3.5 text-destructive" />
</span>
) : (
<WorkspaceAvatar
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
emoji="🐝"
name={workspaceName}
/>
)}
<span className="flex min-w-0 max-w-full items-center gap-1">
<span
className={
degraded
? "min-w-0 truncate text-sm leading-tight text-destructive"
: "min-w-0 truncate text-sm leading-tight text-sidebar-foreground"
}
data-testid="sidebar-workspace-name"
>
{workspaceName}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-sidebar-foreground/45 opacity-0 transition-[opacity,transform] group-hover/workspace-card:opacity-100 group-focus-visible/workspace-card:opacity-100 group-data-[state=open]/workspace-card:rotate-180 group-data-[state=open]/workspace-card:opacity-100" />
</span>
</button>
) : (
<SidebarMenuButton
aria-label={
@@ -336,6 +413,8 @@ export function WorkspaceSwitcher({
switcherDropdown
) : variant === "profile-menu" ? (
profileMenuPopover
) : isSidebarCardVariant ? (
switcherDropdown
) : (
<SidebarMenu>
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>