Polish left sidebar navigation (#495)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-07 12:50:50 -07:00
committed by GitHub
co-authored by Cursor
parent 0dcfa86f84
commit 774632753a
4 changed files with 199 additions and 78 deletions
@@ -26,10 +26,10 @@ export function EphemeralChannelBadge({
<span
{...accessibilityProps}
className={cn(
"inline-flex items-center gap-1 rounded-full font-medium text-amber-700 dark:text-amber-300",
"inline-flex items-center gap-1 rounded-full font-medium text-slate-500 dark:text-slate-400",
isHeader
? "h-5 w-5 justify-center border border-amber-500/30 bg-amber-500/10 p-0 text-xs"
: "shrink-0 h-4 w-4 justify-center border border-amber-500/20 bg-amber-500/10 p-0",
? "h-5 w-5 justify-center border border-sky-500/20 bg-sky-500/5 p-0 text-xs"
: "shrink-0 h-4 w-4 justify-center border border-sky-500/15 bg-slate-500/5 p-0 text-slate-500/80 dark:text-slate-400/80",
)}
data-testid={testId}
title={display.tooltipLabel}
+109 -27
View File
@@ -1,5 +1,14 @@
// biome-ignore format: keep compact to stay within file size limit
import { Activity, Bot, Home, PenSquare, Plus, Search, Zap } from "lucide-react";
import {
Activity,
Bot,
ChevronDown,
Home,
PenSquare,
Plus,
Search,
Zap,
} from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
@@ -25,6 +34,7 @@ import type {
Profile,
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
Sidebar,
@@ -48,6 +58,14 @@ import {
const SECTION_ICON_BUTTON_CLASS =
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
const SECTION_ACTION_VISIBILITY_CLASS =
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
const SECTION_LABEL_BUTTON_CLASS =
"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";
const SECTION_LABEL_CHEVRON_CLASS =
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
type CollapsibleSidebarGroup = "channels" | "forums" | "directMessages";
// ---------------------------------------------------------------------------
// Types
@@ -121,18 +139,25 @@ type AppSidebarProps = {
function SectionHeaderActions({
browseAriaLabel,
browseTestId,
className,
createAriaLabel,
onBrowse,
onCreateClick,
}: {
browseAriaLabel: string;
browseTestId?: string;
className?: string;
createAriaLabel: string;
onBrowse: () => void;
onCreateClick: () => void;
}) {
return (
<div className="absolute right-1 top-3 z-10 flex items-center gap-0.5">
<div
className={cn(
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
className,
)}
>
<button
aria-label={browseAriaLabel}
className={SECTION_ICON_BUTTON_CLASS}
@@ -163,12 +188,14 @@ function ChannelGroupSection({
browseTestId,
createAriaLabel,
groupClassName,
isCollapsed,
isActiveChannel,
items,
listTestId,
onBrowse,
onCreateClick,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
title,
unreadChannelIds,
@@ -177,42 +204,70 @@ function ChannelGroupSection({
browseTestId?: string;
createAriaLabel: string;
groupClassName?: string;
isCollapsed: boolean;
isActiveChannel: boolean;
items: Channel[];
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
title: string;
unreadChannelIds: Set<string>;
}) {
const contentId = `sidebar-${listTestId}`;
return (
<SidebarGroup className={groupClassName}>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SectionHeaderActions
browseAriaLabel={browseAriaLabel}
browseTestId={browseTestId}
createAriaLabel={createAriaLabel}
onBrowse={onBrowse}
onCreateClick={onCreateClick}
/>
<SidebarGroupContent>
{items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
) : null}
</SidebarGroupContent>
<div className="group/sidebar-section relative">
<SidebarGroupLabel asChild>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<ChevronDown
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</SidebarGroupLabel>
<SectionHeaderActions
browseAriaLabel={browseAriaLabel}
browseTestId={browseTestId}
className={SECTION_ACTION_VISIBILITY_CLASS}
createAriaLabel={createAriaLabel}
onBrowse={onBrowse}
onCreateClick={onCreateClick}
/>
</div>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>
{items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
) : null}
</SidebarGroupContent>
) : null}
</SidebarGroup>
);
}
@@ -273,6 +328,23 @@ export function AppSidebar({
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
const [collapsedGroups, setCollapsedGroups] = React.useState<
Record<CollapsibleSidebarGroup, boolean>
>({
channels: false,
forums: false,
directMessages: false,
});
const toggleCollapsedGroup = React.useCallback(
(group: CollapsibleSidebarGroup) => {
setCollapsedGroups((current) => ({
...current,
[group]: !current[group],
}));
},
[],
);
const streamChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "stream"),
@@ -341,6 +413,7 @@ export function AppSidebar({
return (
<Sidebar
className="!border-r-0"
collapsible="offcanvas"
data-testid="app-sidebar"
variant="sidebar"
@@ -458,12 +531,14 @@ export function AppSidebar({
browseTestId="browse-channels"
createAriaLabel="Create a channel"
groupClassName="pt-1"
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
items={streamChannels}
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelIds={unreadChannelIds}
@@ -472,12 +547,14 @@ export function AppSidebar({
browseAriaLabel="Browse forums"
browseTestId="browse-forums"
createAriaLabel="Create a forum"
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
items={forumChannels}
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelIds={unreadChannelIds}
@@ -487,7 +564,10 @@ export function AppSidebar({
<SidebarGroupAction
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
className={cn(
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
@@ -498,11 +578,13 @@ export function AppSidebar({
</SidebarGroupAction>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
testId="dm-list"
@@ -1,5 +1,5 @@
import type * as React from "react";
import { CircleDot, FileText, Hash, Lock, X } from "lucide-react";
import { ChevronDown, CircleDot, FileText, Hash, Lock, X } from "lucide-react";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
@@ -18,6 +18,11 @@ import {
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
const SECTION_LABEL_BUTTON_CLASS =
"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";
const SECTION_LABEL_CHEVRON_CLASS =
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
export type SidebarDmParticipant = {
avatarUrl: string | null;
label: string;
@@ -187,6 +192,7 @@ export function SidebarSection({
emptyState,
items,
channelLabels,
isCollapsed,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
@@ -195,12 +201,14 @@ export function SidebarSection({
unreadChannelIds,
onHideDm,
onSelectChannel,
onToggleCollapsed,
}: {
action?: React.ReactNode;
dmParticipantsByChannelId?: Record<string, SidebarDmParticipant[]>;
emptyState?: React.ReactNode;
items: Channel[];
channelLabels?: Record<string, string>;
isCollapsed?: boolean;
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
@@ -209,60 +217,91 @@ export function SidebarSection({
unreadChannelIds: Set<string>;
onHideDm?: (channelId: string) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed?: () => void;
}) {
if (items.length === 0 && !action && !emptyState) {
return null;
}
const contentId = `sidebar-${testId}`;
const canToggle = Boolean(onToggleCollapsed);
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
{action}
<SidebarGroupContent>
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem className="group/menu-item" key={channel.id}>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
{channel.channelType === "dm" &&
unreadChannelIds.has(channel.id) &&
!(isActiveChannel && selectedChannelId === channel.id) ? (
<span
aria-hidden="true"
className="absolute right-[9px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-primary group-hover/menu-item:hidden"
data-testid={`channel-unread-${channel.name}`}
<div className="group/sidebar-section relative">
<SidebarGroupLabel asChild={canToggle}>
{canToggle ? (
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<ChevronDown
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</button>
) : (
title
)}
</SidebarGroupLabel>
{action}
</div>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem className="group/menu-item" key={channel.id}>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
) : null}
{channel.channelType === "dm" && onHideDm ? (
<SidebarMenuAction
aria-label="Close direct message"
data-testid={`hide-dm-${channel.name}`}
onClick={() => onHideDm(channel.id)}
showOnHover
>
<X />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
))}
</SidebarMenu>
) : emptyState ? (
<div
className="px-2 py-1 text-sm text-sidebar-foreground/60"
data-testid={`${testId}-empty`}
>
{emptyState}
</div>
) : null}
</SidebarGroupContent>
{channel.channelType === "dm" &&
unreadChannelIds.has(channel.id) &&
!(isActiveChannel && selectedChannelId === channel.id) ? (
<span
aria-hidden="true"
className="absolute right-[9px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-primary group-hover/menu-item:hidden"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
{channel.channelType === "dm" && onHideDm ? (
<SidebarMenuAction
aria-label="Close direct message"
data-testid={`hide-dm-${channel.name}`}
onClick={() => onHideDm(channel.id)}
showOnHover
>
<X />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
))}
</SidebarMenu>
) : emptyState ? (
<div
className="px-2 py-1 text-sm text-sidebar-foreground/60"
data-testid={`${testId}-empty`}
>
{emptyState}
</div>
) : null}
</SidebarGroupContent>
) : null}
</SidebarGroup>
);
}
+1 -1
View File
@@ -500,7 +500,7 @@ const SidebarMenu = React.forwardRef<
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
));