Files
buzz/desktop/src/features/sidebar/ui/SidebarSection.tsx
2026-07-17 11:40:05 +00:00

524 lines
17 KiB
TypeScript

import type * as React from "react";
import {
BellOff,
ChevronDown,
CircleDot,
FileText,
Hash,
Lock,
X,
} from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
import {
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
ProfileAvatarWithStatus,
scaleProfileAvatarStatusGeometry,
} from "@/features/profile/ui/ProfileAvatarWithStatus";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
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 =
"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 group-data-[section-actions-open=true]/sidebar-section:opacity-100";
const SECTION_LABEL_CHEVRON_ICON_CLASS =
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
const SIDEBAR_ROW_ACTION_VISIBILITY_CLASS =
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 md:opacity-0";
const SIDEBAR_ROW_ACTION_REPLACED_BADGE_CLASS =
"max-md:opacity-0 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0";
const SIDEBAR_ROW_ICON_ACTION_CLASS =
"flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0";
const DM_AVATAR_SIZE = 24;
const DM_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
DM_AVATAR_SIZE,
);
function formatUnreadCount(count: number): string {
return count > 99 ? "99+" : String(count);
}
function UnreadCountBadge({
channelName,
className,
count,
}: {
channelName: string;
className?: string;
count: number;
}) {
return (
<span
className={cn(
"flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground tabular-nums",
className,
)}
data-testid={`channel-unread-${channelName}`}
>
{formatUnreadCount(count)}
<span className="sr-only"> new comment{count === 1 ? "" : "s"}</span>
</span>
);
}
function UnreadDotBadge({
channelName,
className,
}: {
channelName: string;
className?: string;
}) {
return (
<span
className={cn("h-2 w-2 shrink-0 rounded-full bg-primary", className)}
data-testid={`channel-unread-dot-${channelName}`}
>
<span className="sr-only">unread</span>
</span>
);
}
function formatAgentCount(count: number) {
return `${count} ${count === 1 ? "agent" : "agents"}`;
}
export function formatWorkingTooltip(
summary: ActiveChannelTurnSummary,
): string {
const leadName = summary.agentNames?.[0];
if (!leadName) {
return `${formatAgentCount(summary.agentCount)} working`;
}
const remainingAgentCount = summary.agentCount - 1;
if (remainingAgentCount <= 0) {
return `${leadName} working`;
}
return `${leadName} and ${formatAgentCount(remainingAgentCount)} working`;
}
function ChannelWorkingBadge({
channelName,
isActive,
summary,
}: {
channelName: string;
isActive: boolean;
summary: ActiveChannelTurnSummary;
}) {
const now = useNow(1000);
const elapsed = formatElapsed(now - summary.anchorAt);
const label =
summary.agentCount > 1 ? `${elapsed} (${summary.agentCount})` : elapsed;
const title = formatWorkingTooltip(summary);
return (
<span
className={cn(
"hidden max-w-32 shrink-0 truncate rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums motion-safe:animate-pulse group-data-[collapsible=icon]:hidden sm:inline-flex",
isActive
? "bg-sidebar-active-foreground/20 text-sidebar-active-foreground"
: "bg-primary/10 text-primary",
)}
data-testid={`channel-working-${channelName}`}
title={title}
>
{label}
</span>
);
}
export type SidebarDmParticipant = {
avatarUrl: string | null;
label: string;
pubkey: string;
};
function DmChannelIcon({
channelName,
isPair,
participants,
presenceStatus,
}: {
channelName: string;
isPair: boolean;
participants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
}) {
const primaryParticipant = participants?.[0];
if (!primaryParticipant) {
return <CircleDot className="h-4 w-4" />;
}
if (!isPair && participants && participants.length > 1) {
return (
<span
aria-hidden="true"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-sidebar-accent/80 text-2xs font-semibold leading-none text-sidebar-foreground shadow-none"
data-testid={`channel-dm-count-${channelName}`}
>
<span className="translate-x-px leading-none">
{participants.length}
</span>
</span>
);
}
if (isPair || !participants || participants.length <= 1) {
return (
<span className="relative flex h-6 w-6 shrink-0 items-center justify-center">
<ProfileAvatarWithStatus
avatarClassName="bg-sidebar-accent/80 text-2xs text-sidebar-foreground shadow-none"
avatarUrl={primaryParticipant.avatarUrl}
className="h-6 w-6"
geometry={DM_AVATAR_STATUS_GEOMETRY}
iconClassName="h-3.5 w-3.5"
label={primaryParticipant.label}
size={DM_AVATAR_SIZE}
status={presenceStatus}
statusTestId={`channel-presence-${channelName}`}
/>
</span>
);
}
return <CircleDot className="h-4 w-4" />;
}
function SidebarChannelIcon({
channel,
dmParticipants,
presenceStatus,
}: {
channel: Channel;
dmParticipants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
}) {
if (channel.channelType === "dm") {
return (
<DmChannelIcon
channelName={channel.name}
isPair={channel.participantPubkeys.length === 2}
participants={dmParticipants}
presenceStatus={
dmParticipants?.length === 1 ||
channel.participantPubkeys.length === 2
? presenceStatus
: undefined
}
/>
);
}
if (channel.visibility === "private") {
return <Lock className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
return <Hash className="h-4 w-4" />;
}
export function ChannelMenuButton({
channel,
label,
isActive,
hasUnread,
unreadCount = 0,
activeWorking,
isMuted,
dmParticipants,
presenceStatus,
onSelectChannel,
}: {
channel: Channel;
label?: string;
isActive: boolean;
hasUnread: boolean;
unreadCount?: number;
activeWorking?: ActiveChannelTurnSummary;
isMuted?: boolean;
dmParticipants?: SidebarDmParticipant[];
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
const resolvedLabel = label ?? channel.name;
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
return (
<SidebarMenuButton
className={cn(
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-accent-foreground",
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
!isActive && isMuted && !hasUnread && "opacity-50",
)}
data-channel-id={channel.id}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon
channel={channel}
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
<span className="min-w-0 flex-1 truncate" data-sidebar-row-label>
{resolvedLabel}
</span>
{ephemeralDisplay ? (
<EphemeralChannelBadge
display={ephemeralDisplay}
testId={`channel-ephemeral-${channel.name}`}
variant="sidebar"
/>
) : null}
{activeWorking ? (
<ChannelWorkingBadge
channelName={channel.name}
isActive={isActive}
summary={activeWorking}
/>
) : null}
{isMuted ? (
<BellOff
className={cn(
"ml-auto h-4 w-4 shrink-0",
isActive
? "text-sidebar-active-foreground/60"
: "text-sidebar-foreground/40",
)}
/>
) : null}
{hasUnread && !isActive && channel.channelType !== "dm" ? (
unreadCount > 0 ? (
<UnreadCountBadge
channelName={channel.name}
className="ml-auto"
count={unreadCount}
/>
) : (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
)
) : null}
</SidebarMenuButton>
);
}
export function SidebarSection({
action,
activeWorkingByChannelId,
dmParticipantsByChannelId,
emptyState,
items,
channelLabels,
isCollapsed,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
title,
testId,
unreadChannelCounts,
unreadChannelIds,
onHideDm,
onMarkChannelRead,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
mutedChannelIds,
onMuteChannel,
onUnmuteChannel,
sectionActionsOpen,
}: {
action?: React.ReactNode;
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
dmParticipantsByChannelId?: Record<string, SidebarDmParticipant[]>;
emptyState?: React.ReactNode;
items: Channel[];
channelLabels?: Record<string, string>;
isCollapsed?: boolean;
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
onHideDm?: (channelId: string) => void;
onMarkChannelRead?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (channelId: string) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed?: () => void;
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
sectionActionsOpen?: boolean;
}) {
if (items.length === 0 && !action && !emptyState) {
return null;
}
const contentId = `sidebar-${testId}`;
const canToggle = Boolean(onToggleCollapsed);
return (
<SidebarGroup
className="group/sidebar-section select-none"
data-section-actions-open={sectionActionsOpen || undefined}
>
<div className="relative">
<SidebarGroupLabel asChild={canToggle}>
{canToggle ? (
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
data-testid={`${testId}-section-label`}
onClick={onToggleCollapsed}
type="button"
>
<span data-sidebar-section-title>{title}</span>
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
<ChevronDown
className={cn(
SECTION_LABEL_CHEVRON_ICON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</span>
</button>
) : (
title
)}
</SidebarGroupLabel>
{action}
</div>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => {
const menuItem = (
<SidebarMenuItem
key={onMarkChannelUnread ? undefined : channel.id}
className="group/menu-item"
>
<ChannelMenuButton
channel={channel}
activeWorking={activeWorkingByChannelId?.get(channel.id)}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
isMuted={mutedChannelIds?.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) ? (
<UnreadCountBadge
channelName={channel.name}
className={cn(
"pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
onHideDm && SIDEBAR_ROW_ACTION_REPLACED_BADGE_CLASS,
)}
count={Math.max(
unreadChannelCounts.get(channel.id) ?? 0,
1,
)}
/>
) : null}
{channel.channelType === "dm" && onHideDm ? (
<button
aria-label="Close direct message"
className={cn(
"absolute right-1 top-1/2 z-10 -translate-y-1/2 after:absolute after:-inset-2 after:md:hidden group-data-[collapsible=icon]:hidden",
SIDEBAR_ROW_ICON_ACTION_CLASS,
SIDEBAR_ROW_ACTION_VISIBILITY_CLASS,
)}
data-sidebar="menu-action"
data-testid={`hide-dm-${channel.name}`}
onClick={(event) => {
event.stopPropagation();
onHideDm(channel.id);
}}
type="button"
>
<X />
</button>
) : null}
</SidebarMenuItem>
);
// The shared menu always renders copy actions, so every row
// gets a context menu regardless of read/mute availability.
return (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
<ContextMenuContent>
<ChannelContextMenuItems
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isMuted={mutedChannelIds?.has(channel.id)}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
/>
</ContextMenuContent>
</ContextMenu>
);
})}
</SidebarMenu>
) : emptyState ? (
<div
className="px-2 py-1 text-sm text-sidebar-foreground/60"
data-testid={`${testId}-empty`}
>
{emptyState}
</div>
) : null}
</SidebarGroupContent>
) : null}
</SidebarGroup>
);
}