Skip to content

Commit dcf8833

Browse files
committed
Move workspace switcher to sidebar header
1 parent 40070a5 commit dcf8833

4 files changed

Lines changed: 117 additions & 105 deletions

File tree

desktop/src/features/profile/ui/ProfilePopover.tsx

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ interface ProfilePopoverProps {
3333
// Used when auxiliary triggers (avatar, status text) live alongside the
3434
// primary PopoverTrigger and toggle the popover via controlled `open`.
3535
triggerContainerRef?: React.RefObject<HTMLElement | null>;
36-
// Optional slot rendered between the identity block and the menu items.
37-
// Used by the sidebar to surface the workspace/relay selector inside the
38-
// profile menu instead of on the sidebar card.
39-
workspaceSwitcherSlot?: React.ReactNode;
4036
}
4137

4238
// ---------------------------------------------------------------------------
@@ -68,7 +64,6 @@ export function ProfilePopover({
6864
onOpenSettings,
6965
children,
7066
triggerContainerRef,
71-
workspaceSwitcherSlot,
7267
}: ProfilePopoverProps) {
7368
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
7469
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
@@ -250,8 +245,6 @@ export function ProfilePopover({
250245
</PopoverContent>
251246
</Popover>
252247

253-
<hr className="my-1 h-px border-0 bg-border" />
254-
255248
{/* ── Settings ───────────────────────────────────────── */}
256249
<button
257250
className={MENU_ITEM_CLASS}
@@ -270,16 +263,6 @@ export function ProfilePopover({
270263
{settingsShortcutLabel}
271264
</kbd>
272265
</button>
273-
274-
{workspaceSwitcherSlot ? (
275-
<>
276-
<hr className="my-1 h-px border-0 bg-border" />
277-
{/* ── Workspace / relay selector ─────────────────── */}
278-
<div data-testid="profile-popover-workspace">
279-
{workspaceSwitcherSlot}
280-
</div>
281-
</>
282-
) : null}
283266
</div>
284267
</PopoverContent>
285268
</Popover>

desktop/src/features/sidebar/ui/AppSidebar.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
1515

1616
import type { Workspace } from "@/features/workspaces/types";
1717
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
18+
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
1819
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
1920
import {
2021
useChannelSections,
@@ -529,6 +530,17 @@ export function AppSidebar({
529530
className="mt-(--buzz-top-chrome-height,2.5rem) shrink-0 px-2 pt-2"
530531
data-testid="sidebar-pinned-header"
531532
>
533+
<div className="mb-2.5 group-data-[collapsible=icon]:hidden">
534+
<WorkspaceSwitcher
535+
activeWorkspace={activeWorkspace}
536+
onAddWorkspace={onOpenAddWorkspace}
537+
onRemoveWorkspace={onRemoveWorkspace}
538+
onSwitchWorkspace={onSwitchWorkspace}
539+
onUpdateWorkspace={onUpdateWorkspace}
540+
variant="sidebar-card"
541+
workspaces={workspaces}
542+
/>
543+
</div>
532544
<TopbarSearch
533545
channels={searchChannels}
534546
currentPubkey={currentPubkey}
@@ -860,21 +872,15 @@ export function AppSidebar({
860872
<SidebarMenu>
861873
<SidebarMenuItem>
862874
<SidebarProfileCard
863-
activeWorkspace={activeWorkspace}
864875
isPresencePending={isPresencePending}
865-
onOpenAddWorkspace={onOpenAddWorkspace}
866876
onOpenSettings={onSelectSettings}
867-
onRemoveWorkspace={onRemoveWorkspace}
868877
onSetPresenceStatus={onSetPresenceStatus}
869878
onSetUserStatus={onSetUserStatus}
870879
onClearUserStatus={onClearUserStatus}
871-
onSwitchWorkspace={onSwitchWorkspace}
872-
onUpdateWorkspace={onUpdateWorkspace}
873880
profile={profile}
874881
resolvedDisplayName={resolvedDisplayName}
875882
selfPresenceStatus={selfPresenceStatus}
876883
selfUserStatus={selfUserStatus}
877-
workspaces={workspaces}
878884
/>
879885
</SidebarMenuItem>
880886
</SidebarMenu>

desktop/src/features/sidebar/ui/SidebarProfileCard.tsx

Lines changed: 19 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,48 +6,30 @@ import { useSelfProfileCache } from "@/features/profile/hooks";
66
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
77
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
88
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
9-
import type { Workspace } from "@/features/workspaces/types";
10-
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
119
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
12-
import { cn } from "@/shared/lib/cn";
1310

1411
type SidebarProfileCardProps = {
15-
activeWorkspace: Workspace | null;
1612
isPresencePending?: boolean;
17-
onOpenAddWorkspace: () => void;
1813
onOpenSettings: (section?: "profile" | "appearance") => void;
19-
onRemoveWorkspace: (id: string) => void;
2014
onSetPresenceStatus?: (status: PresenceStatus) => void;
2115
onSetUserStatus: (text: string, emoji: string) => void;
2216
onClearUserStatus: () => void;
23-
onSwitchWorkspace: (id: string) => void;
24-
onUpdateWorkspace: (
25-
id: string,
26-
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
27-
) => void;
2817
profile?: Profile;
2918
resolvedDisplayName: string;
3019
selfPresenceStatus: PresenceStatus;
3120
selfUserStatus?: UserStatus;
32-
workspaces: Workspace[];
3321
};
3422

3523
export function SidebarProfileCard({
36-
activeWorkspace,
3724
isPresencePending,
38-
onOpenAddWorkspace,
3925
onOpenSettings,
40-
onRemoveWorkspace,
4126
onSetPresenceStatus,
4227
onSetUserStatus,
4328
onClearUserStatus,
44-
onSwitchWorkspace,
45-
onUpdateWorkspace,
4629
profile,
4730
resolvedDisplayName,
4831
selfPresenceStatus,
4932
selfUserStatus,
50-
workspaces,
5133
}: SidebarProfileCardProps) {
5234
const selfProfileCache = useSelfProfileCache();
5335
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
@@ -70,18 +52,6 @@ export function SidebarProfileCard({
7052
[toggleProfilePopover],
7153
);
7254
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
73-
const workspaceLabel = activeWorkspace?.name ?? "No workspace";
74-
const readonlyWorkspaceLabel = (
75-
<span className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70">
76-
<span
77-
aria-hidden="true"
78-
className="flex w-3.5 shrink-0 items-center justify-center text-2xs"
79-
>
80-
<span className="-translate-y-px leading-normal">🐝</span>
81-
</span>
82-
<span className="truncate">{workspaceLabel}</span>
83-
</span>
84-
);
8555

8656
return (
8757
// 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({
136106
triggerContainerRef={profileCardRef}
137107
userStatusEmoji={selfUserStatus?.emoji}
138108
userStatusText={selfUserStatus?.text}
139-
workspaceSwitcherSlot={
140-
<WorkspaceSwitcher
141-
activeWorkspace={activeWorkspace}
142-
onAddWorkspace={onOpenAddWorkspace}
143-
onRemoveWorkspace={onRemoveWorkspace}
144-
onSwitchWorkspace={onSwitchWorkspace}
145-
onUpdateWorkspace={onUpdateWorkspace}
146-
variant="profile-menu"
147-
workspaces={workspaces}
148-
/>
149-
}
150109
>
151110
<button
152111
onClick={(event) => {
@@ -167,40 +126,25 @@ export function SidebarProfileCard({
167126
</ProfilePopover>
168127

169128
{hasStatus ? (
170-
<div className="relative mt-0.5">
171-
<button
172-
aria-label={`Open profile menu for ${resolvedDisplayName}`}
173-
className={cn(
174-
"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",
175-
profilePopoverOpen && "opacity-100",
176-
)}
177-
data-testid="sidebar-profile-user-status"
178-
onClick={(event) => {
179-
event.stopPropagation();
180-
toggleProfilePopover();
181-
}}
182-
type="button"
183-
>
184-
{selfUserStatus?.emoji ? (
185-
<StatusEmoji
186-
className="mr-1 w-4 shrink-0 text-xs"
187-
value={selfUserStatus.emoji}
188-
/>
189-
) : null}
190-
<span className="truncate">{selfUserStatus?.text}</span>
191-
</button>
192-
<div
193-
className={cn(
194-
"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",
195-
profilePopoverOpen && "opacity-0",
196-
)}
197-
>
198-
{readonlyWorkspaceLabel}
199-
</div>
200-
</div>
201-
) : (
202-
<div className="relative mt-0.5">{readonlyWorkspaceLabel}</div>
203-
)}
129+
<button
130+
aria-label={`Open profile menu for ${resolvedDisplayName}`}
131+
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"
132+
data-testid="sidebar-profile-user-status"
133+
onClick={(event) => {
134+
event.stopPropagation();
135+
toggleProfilePopover();
136+
}}
137+
type="button"
138+
>
139+
{selfUserStatus?.emoji ? (
140+
<StatusEmoji
141+
className="mr-1 w-4 shrink-0 text-xs"
142+
value={selfUserStatus.emoji}
143+
/>
144+
) : null}
145+
<span className="truncate">{selfUserStatus?.text}</span>
146+
</button>
147+
) : null}
204148
</div>
205149
</div>
206150
</div>

desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
isRelayConnectionDegraded,
2929
useRelayConnection,
3030
} from "@/shared/api/useRelayConnection";
31+
import { cn } from "@/shared/lib/cn";
3132
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
3233

3334
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -42,7 +43,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
4243
type WorkspaceSwitcherProps = {
4344
activeWorkspace: Workspace | null;
4445
workspaces: Workspace[];
45-
variant?: "sidebar" | "profile" | "profile-menu";
46+
variant?: "sidebar" | "sidebar-card" | "profile" | "profile-menu";
4647
onSwitchWorkspace: (id: string) => void;
4748
onAddWorkspace: () => void;
4849
onUpdateWorkspace: (
@@ -52,10 +53,43 @@ type WorkspaceSwitcherProps = {
5253
onRemoveWorkspace: (id: string) => void;
5354
};
5455

55-
function WorkspaceEmojiIcon({ className }: { className: string }) {
56+
function getWorkspaceInitial(name: string): string {
57+
return name.trim().charAt(0).toUpperCase() || "W";
58+
}
59+
60+
function WorkspaceAvatar({
61+
className,
62+
emoji,
63+
imageUrl,
64+
name,
65+
}: {
66+
className: string;
67+
emoji?: string;
68+
imageUrl?: string | null;
69+
name: string;
70+
}) {
5671
return (
57-
<span aria-hidden="true" className={className}>
58-
<span className="-translate-y-px leading-normal">🐝</span>
72+
<span
73+
aria-hidden="true"
74+
className={cn(
75+
"overflow-hidden rounded-xl bg-sidebar-accent/40 text-sidebar-foreground/80",
76+
className,
77+
)}
78+
>
79+
{imageUrl ? (
80+
<img
81+
alt=""
82+
className="h-full w-full object-cover"
83+
draggable={false}
84+
src={imageUrl}
85+
/>
86+
) : emoji ? (
87+
<span className="-translate-y-px leading-normal">{emoji}</span>
88+
) : (
89+
<span className="font-medium leading-none">
90+
{getWorkspaceInitial(name)}
91+
</span>
92+
)}
5993
</span>
6094
);
6195
}
@@ -77,6 +111,8 @@ export function WorkspaceSwitcher({
77111
const degraded = isRelayConnectionDegraded(connectionState);
78112
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
79113
const isProfileVariant = variant === "profile";
114+
const isSidebarCardVariant = variant === "sidebar-card";
115+
const workspaceName = activeWorkspace?.name ?? "No workspace";
80116

81117
function clearProfileMenuHoverTimer() {
82118
if (profileMenuHoverTimer.current !== null) {
@@ -137,12 +173,14 @@ export function WorkspaceSwitcher({
137173
</TooltipContent>
138174
</Tooltip>
139175
) : (
140-
<WorkspaceEmojiIcon
176+
<WorkspaceAvatar
141177
className={
142178
isProfileVariant
143-
? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
144-
: "flex w-5 shrink-0 items-center justify-center text-xs"
179+
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-2xs"
180+
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs"
145181
}
182+
emoji="🐝"
183+
name={workspaceName}
146184
/>
147185
)}
148186
<span
@@ -270,6 +308,45 @@ export function WorkspaceSwitcher({
270308
>
271309
{triggerContent}
272310
</button>
311+
) : isSidebarCardVariant ? (
312+
<button
313+
aria-label={
314+
degraded
315+
? `${workspaceName}${connectionLabel}`
316+
: `Switch workspace: ${workspaceName}`
317+
}
318+
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"
319+
data-testid="sidebar-workspace-card"
320+
type="button"
321+
>
322+
{degraded ? (
323+
<span
324+
aria-hidden="true"
325+
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
326+
>
327+
<WifiOff className="h-3.5 w-3.5 text-destructive" />
328+
</span>
329+
) : (
330+
<WorkspaceAvatar
331+
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
332+
emoji="🐝"
333+
name={workspaceName}
334+
/>
335+
)}
336+
<span className="flex min-w-0 max-w-full items-center gap-1">
337+
<span
338+
className={
339+
degraded
340+
? "min-w-0 truncate text-sm leading-tight text-destructive"
341+
: "min-w-0 truncate text-sm leading-tight text-sidebar-foreground"
342+
}
343+
data-testid="sidebar-workspace-name"
344+
>
345+
{workspaceName}
346+
</span>
347+
<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" />
348+
</span>
349+
</button>
273350
) : (
274351
<SidebarMenuButton
275352
aria-label={
@@ -336,6 +413,8 @@ export function WorkspaceSwitcher({
336413
switcherDropdown
337414
) : variant === "profile-menu" ? (
338415
profileMenuPopover
416+
) : isSidebarCardVariant ? (
417+
switcherDropdown
339418
) : (
340419
<SidebarMenu>
341420
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>

0 commit comments

Comments
 (0)