Skip to content

Commit 0a655ab

Browse files
committed
Add sidebar workplace switcher
1 parent 0d052cb commit 0a655ab

7 files changed

Lines changed: 163 additions & 134 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: 28 additions & 20 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,
@@ -44,11 +45,11 @@ import {
4445
SidebarLoadingContent,
4546
useSidebarLoadingShape,
4647
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
48+
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
4749
import {
4850
SECTION_ACTION_VISIBILITY_CLASS,
4951
SECTION_ICON_BUTTON_CLASS,
5052
} from "@/features/sidebar/ui/sidebarSectionStyles";
51-
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
5253
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
5354
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
5455
import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
@@ -549,18 +550,31 @@ export function AppSidebar({
549550
className="mt-(--buzz-top-chrome-height,2.5rem) shrink-0 px-2 pt-2"
550551
data-testid="sidebar-pinned-header"
551552
>
552-
<TopbarSearch
553-
channelLabels={dmChannelLabels}
554-
channels={searchChannels}
555-
currentPubkey={currentPubkey}
556-
focusRequest={searchFocusRequest}
557-
onOpenChannel={onSelectChannel}
558-
onOpenResult={onOpenSearchResult}
559-
onOpenUser={(user) => onOpenDm({ pubkeys: [user.pubkey] })}
560-
onCreateAgent={onCreateAgent}
561-
onCreateChannel={handleOpenCreateChannel}
562-
suggestionChannels={channels}
563-
/>
553+
<div className="mb-2.5 flex items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
554+
<WorkspaceSwitcher
555+
activeWorkspace={activeWorkspace}
556+
onAddWorkspace={onOpenAddWorkspace}
557+
onRemoveWorkspace={onRemoveWorkspace}
558+
onSwitchWorkspace={onSwitchWorkspace}
559+
onUpdateWorkspace={onUpdateWorkspace}
560+
variant="sidebar-card"
561+
workspaces={workspaces}
562+
/>
563+
<TopbarSearch
564+
channelLabels={dmChannelLabels}
565+
channels={searchChannels}
566+
className="mr-1 shrink-0"
567+
currentPubkey={currentPubkey}
568+
focusRequest={searchFocusRequest}
569+
onOpenChannel={onSelectChannel}
570+
onOpenResult={onOpenSearchResult}
571+
onOpenUser={(user) => onOpenDm({ pubkeys: [user.pubkey] })}
572+
onCreateAgent={onCreateAgent}
573+
onCreateChannel={handleOpenCreateChannel}
574+
suggestionChannels={channels}
575+
variant="icon"
576+
/>
577+
</div>
564578
<SidebarHeader
565579
className="cursor-default select-none px-0 pb-0 pt-2"
566580
data-tauri-drag-region
@@ -827,7 +841,7 @@ export function AppSidebar({
827841
presenceByChannelId={dmPresenceByChannelId}
828842
selectedChannelId={selectedChannelId}
829843
testId="dm-list"
830-
title="Direct Messages"
844+
title="Direct messages"
831845
unreadChannelCounts={unreadChannelCounts}
832846
unreadChannelIds={unreadChannelIds}
833847
mutedChannelIds={mutedChannelIds}
@@ -885,21 +899,15 @@ export function AppSidebar({
885899
<SidebarMenu>
886900
<SidebarMenuItem>
887901
<SidebarProfileCard
888-
activeWorkspace={activeWorkspace}
889902
isPresencePending={isPresencePending}
890-
onOpenAddWorkspace={onOpenAddWorkspace}
891903
onOpenSettings={onSelectSettings}
892-
onRemoveWorkspace={onRemoveWorkspace}
893904
onSetPresenceStatus={onSetPresenceStatus}
894905
onSetUserStatus={onSetUserStatus}
895906
onClearUserStatus={onClearUserStatus}
896-
onSwitchWorkspace={onSwitchWorkspace}
897-
onUpdateWorkspace={onUpdateWorkspace}
898907
profile={profile}
899908
resolvedDisplayName={resolvedDisplayName}
900909
selfPresenceStatus={selfPresenceStatus}
901910
selfUserStatus={selfUserStatus}
902-
workspaces={workspaces}
903911
/>
904912
</SidebarMenuItem>
905913
</SidebarMenu>

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
GripVertical,
1212
Pencil,
1313
Plus,
14-
Search,
1514
Star,
1615
StarOff,
1716
Trash2,
@@ -48,6 +47,7 @@ import {
4847
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
4948
import type { Channel } from "@/shared/api/types";
5049
import { cn } from "@/shared/lib/cn";
50+
import { HashSearch } from "@/shared/ui/icons";
5151

5252
// ---------------------------------------------------------------------------
5353
// Shared styles
@@ -56,7 +56,7 @@ import { cn } from "@/shared/lib/cn";
5656
const SECTION_LABEL_BUTTON_CLASS =
5757
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
5858
const SECTION_LABEL_CHEVRON_CLASS =
59-
"relative size-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:text-sidebar-foreground group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-within/sidebar-section:opacity-100 group-focus-within/sidebar-section:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
59+
"relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100";
6060
const SECTION_LABEL_CHEVRON_ICON_CLASS =
6161
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
6262

@@ -261,18 +261,24 @@ function SectionHeaderActions({
261261
{onBrowseClick ? (
262262
<button
263263
aria-label={browseAriaLabel}
264-
className={SECTION_ICON_BUTTON_CLASS}
264+
className={cn(
265+
SECTION_ICON_BUTTON_CLASS,
266+
SECTION_ACTION_VISIBILITY_CLASS,
267+
)}
265268
onClick={onBrowseClick}
266269
title={browseAriaLabel}
267270
type="button"
268271
>
269-
<Search className="h-4 w-4" />
272+
<HashSearch className="h-4 w-4" />
270273
</button>
271274
) : null}
272275
{onCreateClick ? (
273276
<button
274277
aria-label={createAriaLabel}
275-
className={SECTION_ICON_BUTTON_CLASS}
278+
className={cn(
279+
SECTION_ICON_BUTTON_CLASS,
280+
SECTION_ACTION_VISIBILITY_CLASS,
281+
)}
276282
onClick={onCreateClick}
277283
type="button"
278284
>
@@ -416,8 +422,8 @@ export function ChannelGroupSection({
416422
) : null;
417423

418424
const sectionContent = (
419-
<SidebarGroup className={groupClassName}>
420-
<div className="group/sidebar-section relative">
425+
<SidebarGroup className={cn("group/sidebar-section", groupClassName)}>
426+
<div className="relative">
421427
<SidebarGroupLabel asChild>
422428
<button
423429
aria-controls={contentId}
@@ -535,13 +541,12 @@ export function CustomChannelSection({
535541
<SortableSectionShell sectionId={section.id}>
536542
{({ dragHandleProps, isDragging }) => (
537543
<DroppableSectionBody sectionId={section.id}>
538-
<SidebarGroup className={cn(isDragging && "opacity-30")}>
544+
<SidebarGroup
545+
className={cn("group/sidebar-section", isDragging && "opacity-30")}
546+
>
539547
<ContextMenu>
540548
<ContextMenuTrigger asChild>
541-
<div
542-
className="group/sidebar-section relative"
543-
{...dragHandleProps}
544-
>
549+
<div className="relative" {...dragHandleProps}>
545550
<SidebarGroupLabel asChild>
546551
<button
547552
aria-controls={contentId}

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/sidebar/ui/SidebarSection.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
3535
const SECTION_LABEL_BUTTON_CLASS =
3636
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
3737
const SECTION_LABEL_CHEVRON_CLASS =
38-
"relative size-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:text-sidebar-foreground group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-within/sidebar-section:opacity-100 group-focus-within/sidebar-section:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
38+
"relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100";
3939
const SECTION_LABEL_CHEVRON_ICON_CLASS =
4040
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
4141
const SIDEBAR_ROW_ACTION_VISIBILITY_CLASS =
@@ -300,8 +300,8 @@ export function SidebarSection({
300300
const canToggle = Boolean(onToggleCollapsed);
301301

302302
return (
303-
<SidebarGroup>
304-
<div className="group/sidebar-section relative">
303+
<SidebarGroup className="group/sidebar-section">
304+
<div className="relative">
305305
<SidebarGroupLabel asChild={canToggle}>
306306
{canToggle ? (
307307
<button

0 commit comments

Comments
 (0)