feat(desktop): unify sidebar section actions into a per-section ⋮ menu (#1577)

Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-07-07 15:29:02 +01:00
committed by GitHub
parent ed84a87358
commit d94561795a
13 changed files with 731 additions and 514 deletions

View File

@@ -1,6 +1,4 @@
// biome-ignore format: keep compact to stay within file size limit
import { MessageCirclePlus } from "lucide-react";
import * as React from "react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
@@ -36,8 +34,9 @@ import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
import {
ChannelGroupSection,
CustomChannelSection,
SectionActionsMenu,
SectionQuickAction,
} from "@/features/sidebar/ui/CustomChannelSection";
import { ChannelSortDropdown } from "@/features/sidebar/ui/ChannelSortDropdown";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
@@ -47,10 +46,6 @@ import {
SidebarLoadingContent,
useSidebarLoadingShape,
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
import {
SECTION_ACTION_VISIBILITY_CLASS,
SECTION_ICON_BUTTON_CLASS,
} from "@/features/sidebar/ui/sidebarSectionStyles";
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
@@ -592,7 +587,6 @@ export function AppSidebar({
<>
{starredChannels.length > 0 ? (
<ChannelGroupSection
createAriaLabel="Starred channels"
hasUnread={starredChannels.some((c) =>
unreadChannelIds.has(c.id),
)}
@@ -600,16 +594,9 @@ export function AppSidebar({
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={starredChannels}
leadingHeaderAction={
<ChannelSortDropdown
groupLabel="starred channels"
onSortModeChange={(mode) =>
setSortModeFor("starred", mode)
}
sortMode={sortModeFor("starred")}
testId="channel-sort-trigger-starred"
/>
}
sortMode={sortModeFor("starred")}
onSortModeChange={(mode) => setSortModeFor("starred", mode)}
actionsTestId="section-actions-starred"
listTestId="starred-list"
onMarkAllRead={() => {
for (const channel of starredChannels) {
@@ -661,21 +648,9 @@ export function AppSidebar({
assignments={channelAssignments}
isFirst={idx === 0}
isLast={idx === channelSections.length - 1}
leadingHeaderAction={
<ChannelSortDropdown
groupLabel={section.name}
onSortModeChange={(mode) =>
setSortModeFor(
sectionSortGroupKey(section.id),
mode,
)
}
sortMode={sortModeFor(
sectionSortGroupKey(section.id),
)}
testId={`channel-sort-trigger-section-${section.id}`}
visibilityClassName=""
/>
sortMode={sortModeFor(sectionSortGroupKey(section.id))}
onSortModeChange={(mode) =>
setSortModeFor(sectionSortGroupKey(section.id), mode)
}
onToggleCollapsed={() =>
toggleCollapsedSection(section.id)
@@ -707,27 +682,23 @@ export function AppSidebar({
/>
))}
<ChannelGroupSection
browseAriaLabel="Browse channels"
createAriaLabel="Create a channel"
browseLabel="Browse channels"
createLabel="New channel"
draggable
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={sectionBuckets.unassigned}
leadingHeaderAction={
<ChannelSortDropdown
groupLabel="channels"
onSortModeChange={(mode) =>
setSortModeFor("channels", mode)
}
sortMode={sortModeFor("channels")}
testId="channel-sort-trigger-channels"
/>
sortMode={sortModeFor("channels")}
onSortModeChange={(mode) =>
setSortModeFor("channels", mode)
}
actionsTestId="section-actions-channels"
listTestId="stream-list"
onBrowseClick={onBrowseChannels}
onCreateClick={() => openCreateDialog("stream")}
showQuickCreate
onMarkAllRead={onMarkAllChannelsRead}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
@@ -753,22 +724,15 @@ export function AppSidebar({
</SidebarDndContext>
<FeatureGate feature="forum">
<ChannelGroupSection
createAriaLabel="Create a forum"
createLabel="New forum"
hasUnread={unreadChannelIds.size > 0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
items={forumChannels}
leadingHeaderAction={
<ChannelSortDropdown
groupLabel="forums"
onSortModeChange={(mode) =>
setSortModeFor("forums", mode)
}
sortMode={sortModeFor("forums")}
testId="channel-sort-trigger-forums"
/>
}
sortMode={sortModeFor("forums")}
onSortModeChange={(mode) => setSortModeFor("forums", mode)}
actionsTestId="section-actions-forums"
listTestId="forum-list"
onCreateClick={() => openCreateDialog("forum")}
onMarkAllRead={onMarkAllChannelsRead}
@@ -788,25 +752,18 @@ export function AppSidebar({
<SidebarSection
action={
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<ChannelSortDropdown
groupLabel="direct messages"
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
sortMode={sortModeFor("dms")}
testId="channel-sort-trigger-dms"
<SectionQuickAction
label="New message"
onClick={() => setIsNewDmOpen(true)}
testId="section-actions-dms-quick-create"
/>
<SectionActionsMenu
sectionLabel="direct messages"
testId="section-actions-dms"
onNewMessage={() => setIsNewDmOpen(true)}
sortMode={sortModeFor("dms")}
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
/>
<button
aria-expanded={isNewDmOpen}
aria-label="Compose new message"
className={`${SECTION_ICON_BUTTON_CLASS} ${SECTION_ACTION_VISIBILITY_CLASS}`}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
title="Compose new message"
type="button"
>
<MessageCirclePlus className="h-4 w-4" />
</button>
</div>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}

View File

@@ -0,0 +1,284 @@
import {
Bell,
BellOff,
Check,
CheckCircle2,
CircleDot,
Copy,
LogOut,
Plus,
Star,
StarOff,
} from "lucide-react";
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import {
ContextMenuIconSlot,
deferMenuAction,
} from "@/features/sidebar/ui/sidebarMenuHelpers";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { Channel } from "@/shared/api/types";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/shared/ui/context-menu";
function MoveToSectionSubmenu({
channelId,
sections,
assignments,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
}: {
channelId: string;
sections: ChannelSection[];
assignments: Record<string, string>;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onCreateSectionForChannel: (channelId: string) => void;
}) {
const currentSectionId = assignments[channelId];
return (
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuIconSlot />
<span>Move to section</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{sections.map((section) => (
<ContextMenuItem
key={section.id}
onSelect={() =>
deferMenuAction(() => onAssignChannel(channelId, section.id))
}
>
<ContextMenuIconSlot>
{currentSectionId === section.id ? (
<Check className="h-4 w-4" />
) : section.icon ? (
<StatusEmoji className="h-4 w-4" value={section.icon} />
) : null}
</ContextMenuIconSlot>
<span>{section.name}</span>
</ContextMenuItem>
))}
{sections.length > 0 ? <ContextMenuSeparator /> : null}
<ContextMenuItem
onSelect={() =>
deferMenuAction(() => onCreateSectionForChannel(channelId))
}
>
<ContextMenuIconSlot>
<Plus className="h-4 w-4" />
</ContextMenuIconSlot>
<span>New section...</span>
</ContextMenuItem>
{currentSectionId ? (
<ContextMenuItem
onSelect={() => deferMenuAction(() => onUnassignChannel(channelId))}
>
<ContextMenuIconSlot />
<span>Remove from section</span>
</ContextMenuItem>
) : null}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
/**
* The channel/DM context menu's Copy actions, grouped under a single
* "Copy" submenu (channel name / channel ID).
*/
function CopyChannelSubmenu({ channel }: { channel: Channel }) {
return (
<ContextMenuSub>
<ContextMenuSubTrigger>
<ContextMenuIconSlot>
<Copy className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Copy</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() =>
copyTextToClipboard(
channel.name,
"Channel name copied to clipboard",
)
}
>
<span>Copy channel name</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
copyTextToClipboard(channel.id, "Channel ID copied to clipboard")
}
>
<span>Copy channel ID</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
export function ChannelContextMenuItems({
channel,
hasUnread,
isMuted,
isStarred,
sections,
assignments,
onMarkChannelRead,
onMarkChannelUnread,
onMuteChannel,
onUnmuteChannel,
onStarChannel,
onUnstarChannel,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onLeaveChannel,
}: {
channel: Channel;
hasUnread: boolean;
isMuted?: boolean;
isStarred?: boolean;
sections?: ChannelSection[];
assignments?: Record<string, string>;
onMarkChannelRead?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (channelId: string) => void;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
: Boolean(onMarkChannelUnread);
const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel);
const showMove = Boolean(
sections &&
assignments &&
onAssignChannel &&
onUnassignChannel &&
onCreateSectionForChannel,
);
return (
<>
<CopyChannelSubmenu channel={channel} />
{showMove ? (
<MoveToSectionSubmenu
channelId={channel.id}
sections={sections ?? []}
assignments={assignments ?? {}}
onAssignChannel={onAssignChannel ?? (() => {})}
onUnassignChannel={onUnassignChannel ?? (() => {})}
onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
/>
) : null}
{showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
onMarkChannelRead(channel.id, channel.lastMessageAt),
)
}
>
<ContextMenuIconSlot>
<CheckCircle2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Mark as read</span>
</ContextMenuItem>
) : !hasUnread && onMarkChannelUnread ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() => onMarkChannelUnread(channel.id))
}
>
<ContextMenuIconSlot>
<CircleDot className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Mark unread</span>
</ContextMenuItem>
) : null}
{showMuteToggle || showStar ? <ContextMenuSeparator /> : null}
{showMuteToggle ? (
isMuted ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() => onUnmuteChannel?.(channel.id))
}
>
<ContextMenuIconSlot>
<Bell className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Unmute channel</span>
</ContextMenuItem>
) : (
<ContextMenuItem
onSelect={() => deferMenuAction(() => onMuteChannel?.(channel.id))}
>
<ContextMenuIconSlot>
<BellOff className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Mute channel</span>
</ContextMenuItem>
)
) : null}
{showStar ? (
isStarred ? (
<ContextMenuItem
onSelect={() =>
deferMenuAction(() => onUnstarChannel?.(channel.id))
}
>
<ContextMenuIconSlot>
<StarOff className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Unstar channel</span>
</ContextMenuItem>
) : (
<ContextMenuItem
onSelect={() => deferMenuAction(() => onStarChannel?.(channel.id))}
>
<ContextMenuIconSlot>
<Star className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Star channel</span>
</ContextMenuItem>
)
) : null}
{onLeaveChannel ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => deferMenuAction(() => onLeaveChannel(channel))}
>
<ContextMenuIconSlot>
<LogOut className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Leave channel</span>
</ContextMenuItem>
</>
) : null}
</>
);
}

View File

@@ -1,72 +0,0 @@
import { ArrowUpDown } from "lucide-react";
import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference";
import {
SECTION_ACTION_VISIBILITY_CLASS,
SECTION_ICON_BUTTON_CLASS,
} from "@/features/sidebar/ui/sidebarSectionStyles";
import { cn } from "@/shared/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [
{ value: "recent", label: "Recent" },
{ value: "alpha", label: "A–Z" },
];
/**
* Section-header dropdown for a single sidebar grouping's sort preference.
* Every grouping (Starred, each custom section, Channels, Forums, DMs)
* carries its own control and saved mode; grouping boundaries are untouched.
*/
export function ChannelSortDropdown({
groupLabel,
sortMode,
onSortModeChange,
testId,
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
}: {
groupLabel: string;
sortMode: ChannelSortMode;
onSortModeChange: (mode: ChannelSortMode) => void;
testId?: string;
visibilityClassName?: string;
}) {
const activeLabel =
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "A–Z";
const ariaLabel = `Sort ${groupLabel}: ${activeLabel}`;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={ariaLabel}
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
data-testid={testId ?? "channel-sort-trigger"}
onClick={(e) => e.stopPropagation()}
title={ariaLabel}
type="button"
>
<ArrowUpDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
onValueChange={(value) => onSortModeChange(value as ChannelSortMode)}
value={sortMode}
>
{SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,37 +1,38 @@
import {
ArrowDown,
ArrowUp,
Bell,
BellOff,
Check,
ArrowUpDown,
CheckCheck,
CheckCircle2,
ChevronDown,
CircleDot,
Clipboard,
Copy,
GripVertical,
LogOut,
EllipsisVertical,
Pencil,
Plus,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { useRef } from "react";
import type * as React from "react";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
@@ -40,6 +41,8 @@ import {
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection";
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
import { deferMenuAction } from "@/features/sidebar/ui/sidebarMenuHelpers";
import {
DraggableChannelRow,
DroppableSectionBody,
@@ -64,262 +67,264 @@ const SECTION_LABEL_CHEVRON_CLASS =
const SECTION_LABEL_CHEVRON_ICON_CLASS =
"absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
function MoveToSectionSubmenu({
channelId,
sections,
assignments,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
}: {
channelId: string;
sections: ChannelSection[];
assignments: Record<string, string>;
onAssignChannel: (channelId: string, sectionId: string) => void;
onUnassignChannel: (channelId: string) => void;
onCreateSectionForChannel: (channelId: string) => void;
}) {
const currentSectionId = assignments[channelId];
const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [
{ value: "recent", label: "Recent" },
{ value: "alpha", label: "A–Z" },
];
/**
* A single always-visible "+" quick action shown at the right edge of a
* section header, to the left of the ⋮ menu. Used for the most common
* per-section create action (New channel, New message) while every other
* action stays folded into {@link SectionActionsMenu}.
*/
export function SectionQuickAction({
label,
onClick,
testId,
icon: Icon = Plus,
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
}: {
label: string;
onClick: () => void;
testId?: string;
icon?: typeof Plus;
visibilityClassName?: string;
}) {
return (
<ContextMenuSub>
<ContextMenuSubTrigger>Move to section</ContextMenuSubTrigger>
<ContextMenuSubContent>
{sections.map((section) => (
<ContextMenuItem
key={section.id}
onClick={() => onAssignChannel(channelId, section.id)}
>
{currentSectionId === section.id ? (
<Check className="h-4 w-4" />
) : section.icon ? (
<StatusEmoji className="h-4 w-4" value={section.icon} />
) : (
<span className="h-4 w-4" />
)}
{section.name}
</ContextMenuItem>
))}
{sections.length > 0 ? <ContextMenuSeparator /> : null}
<ContextMenuItem onClick={() => onCreateSectionForChannel(channelId)}>
<Plus className="h-4 w-4" />
New section...
</ContextMenuItem>
{currentSectionId ? (
<ContextMenuItem onClick={() => onUnassignChannel(channelId)}>
Remove from section
</ContextMenuItem>
) : null}
</ContextMenuSubContent>
</ContextMenuSub>
<button
aria-label={label}
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
data-testid={testId}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
onPointerDown={(event) => event.stopPropagation()}
title={label}
type="button"
>
<Icon className="h-4 w-4" />
</button>
);
}
export function ChannelContextMenuItems({
channel,
/**
* The single "more actions" menu shown at the right edge of every sidebar
* section header (Starred, Channels, Forums, Direct messages, and each custom
* section). Items render only when their handler is provided; a Sort radio
* group is appended whenever a sort preference is supplied.
*/
export function SectionActionsMenu({
sectionLabel,
testId,
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
hasUnread,
isMuted,
isStarred,
sections,
assignments,
onMarkChannelRead,
onMarkChannelUnread,
onMuteChannel,
onUnmuteChannel,
onStarChannel,
onUnstarChannel,
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onLeaveChannel,
}: {
channel: Channel;
hasUnread: boolean;
isMuted?: boolean;
isStarred?: boolean;
sections?: ChannelSection[];
assignments?: Record<string, string>;
onMarkChannelRead?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkChannelUnread?: (channelId: string) => void;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
: Boolean(onMarkChannelUnread);
return (
<>
{showStar ? (
isStarred ? (
<ContextMenuItem onClick={() => onUnstarChannel?.(channel.id)}>
<StarOff className="h-4 w-4" />
Unstar channel
</ContextMenuItem>
) : (
<ContextMenuItem onClick={() => onStarChannel?.(channel.id)}>
<Star className="h-4 w-4" />
Star channel
</ContextMenuItem>
)
) : null}
{showStar && showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
<ContextMenuItem
onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
>
<CheckCircle2 className="h-4 w-4" />
Mark as read
</ContextMenuItem>
) : !hasUnread && onMarkChannelUnread ? (
<ContextMenuItem onClick={() => onMarkChannelUnread(channel.id)}>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
) : null}
{onMuteChannel && onUnmuteChannel ? (
<>
<ContextMenuSeparator />
{isMuted ? (
<ContextMenuItem onClick={() => onUnmuteChannel(channel.id)}>
<Bell className="h-4 w-4" />
Unmute channel
</ContextMenuItem>
) : (
<ContextMenuItem onClick={() => onMuteChannel(channel.id)}>
<BellOff className="h-4 w-4" />
Mute channel
</ContextMenuItem>
)}
</>
) : null}
{sections &&
assignments &&
onAssignChannel &&
onUnassignChannel &&
onCreateSectionForChannel ? (
<>
<ContextMenuSeparator />
<MoveToSectionSubmenu
channelId={channel.id}
sections={sections}
assignments={assignments}
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
/>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() =>
copyTextToClipboard(channel.name, "Channel name copied to clipboard")
}
>
<Copy className="h-4 w-4" />
Copy channel name
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
copyTextToClipboard(channel.id, "Channel ID copied to clipboard")
}
>
<Clipboard className="h-4 w-4" />
Copy channel ID
</ContextMenuItem>
{onLeaveChannel ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onLeaveChannel(channel)}
>
<LogOut className="h-4 w-4" />
Leave channel
</ContextMenuItem>
</>
) : null}
</>
);
}
function SectionHeaderActions({
browseAriaLabel,
createAriaLabel,
hasUnread,
leadingAction,
onBrowseClick,
onCreateClick,
onMarkAllRead,
onBrowse,
browseLabel,
onCreate,
createLabel,
onNewMessage,
newMessageLabel,
onRenameSection,
onMoveSectionUp,
onMoveSectionDown,
onDeleteSection,
isFirstSection,
isLastSection,
sortMode,
onSortModeChange,
}: {
browseAriaLabel?: string;
createAriaLabel: string;
sectionLabel: string;
testId?: string;
visibilityClassName?: string;
hasUnread?: boolean;
leadingAction?: React.ReactNode;
onBrowseClick?: () => void;
onCreateClick?: () => void;
onMarkAllRead?: () => void;
onBrowse?: () => void;
browseLabel?: string;
onCreate?: () => void;
createLabel?: string;
onNewMessage?: () => void;
newMessageLabel?: string;
onRenameSection?: () => void;
onMoveSectionUp?: () => void;
onMoveSectionDown?: () => void;
onDeleteSection?: () => void;
isFirstSection?: boolean;
isLastSection?: boolean;
sortMode?: ChannelSortMode;
onSortModeChange?: (mode: ChannelSortMode) => void;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const showSectionManagement = Boolean(onRenameSection || onDeleteSection);
const showSort = Boolean(sortMode && onSortModeChange);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`More actions for ${sectionLabel}`}
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
data-testid={testId}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={triggerRef}
type="button"
>
<EllipsisVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.blur();
}}
>
{hasUnread && onMarkAllRead ? (
<DropdownMenuItem onSelect={() => deferMenuAction(onMarkAllRead)}>
<CheckCheck className="h-4 w-4" />
<span>Mark all as read</span>
</DropdownMenuItem>
) : null}
{onNewMessage ? (
<DropdownMenuItem onSelect={() => deferMenuAction(onNewMessage)}>
<Plus className="h-4 w-4" />
<span>{newMessageLabel ?? "New message"}</span>
</DropdownMenuItem>
) : null}
{onBrowse ? (
<DropdownMenuItem onSelect={() => deferMenuAction(onBrowse)}>
<HashSearch className="h-4 w-4" />
<span>{browseLabel ?? "Browse channels"}</span>
</DropdownMenuItem>
) : null}
{onCreate ? (
<DropdownMenuItem onSelect={() => deferMenuAction(onCreate)}>
<Plus className="h-4 w-4" />
<span>{createLabel ?? "Create channel"}</span>
</DropdownMenuItem>
) : null}
{showSectionManagement ? (
<>
{onRenameSection ? (
<DropdownMenuItem
onSelect={() => deferMenuAction(onRenameSection)}
>
<Pencil className="h-4 w-4" />
<span>Rename section</span>
</DropdownMenuItem>
) : null}
{onMoveSectionUp ? (
<DropdownMenuItem
disabled={isFirstSection}
onSelect={() => deferMenuAction(onMoveSectionUp)}
>
<ArrowUp className="h-4 w-4" />
<span>Move up</span>
</DropdownMenuItem>
) : null}
{onMoveSectionDown ? (
<DropdownMenuItem
disabled={isLastSection}
onSelect={() => deferMenuAction(onMoveSectionDown)}
>
<ArrowDown className="h-4 w-4" />
<span>Move down</span>
</DropdownMenuItem>
) : null}
</>
) : null}
{showSort ? (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowUpDown className="h-4 w-4" />
<span>Sort</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={(value) =>
onSortModeChange?.(value as ChannelSortMode)
}
value={sortMode}
>
{SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
) : null}
{onDeleteSection ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => deferMenuAction(onDeleteSection)}
>
<Trash2 className="h-4 w-4" />
<span>Delete section</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function ChannelSectionHeader({
contentId,
isCollapsed,
onToggleCollapsed,
title,
actions,
}: {
contentId: string;
isCollapsed: boolean;
onToggleCollapsed: () => void;
title: string;
actions: React.ReactNode;
}) {
return (
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
{leadingAction}
{hasUnread && onMarkAllRead ? (
<div className="relative">
<SidebarGroupLabel asChild>
<button
aria-label="Mark all as read"
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
)}
onClick={onMarkAllRead}
title="Mark all as read"
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<CheckCheck className="h-4 w-4" />
<span>{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>
) : null}
{onBrowseClick ? (
<button
aria-label={browseAriaLabel}
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
)}
onClick={onBrowseClick}
title={browseAriaLabel}
type="button"
>
<HashSearch className="h-4 w-4" />
</button>
) : null}
{onCreateClick ? (
<button
aria-label={createAriaLabel}
className={cn(
SECTION_ICON_BUTTON_CLASS,
SECTION_ACTION_VISIBILITY_CLASS,
)}
onClick={onCreateClick}
type="button"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</SidebarGroupLabel>
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
{actions}
</div>
</div>
);
}
export function ChannelGroupSection({
browseAriaLabel,
createAriaLabel,
browseLabel,
createLabel,
draggable,
groupClassName,
hasUnread,
@@ -327,16 +332,19 @@ export function ChannelGroupSection({
isActiveChannel,
activeWorkingByChannelId,
items,
leadingHeaderAction,
listTestId,
onBrowseClick,
onCreateClick,
showQuickCreate,
onMarkAllRead,
onMarkChannelRead,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
sortMode,
onSortModeChange,
actionsTestId,
title,
unreadChannelCounts,
unreadChannelIds,
@@ -353,18 +361,18 @@ export function ChannelGroupSection({
onUnstarChannel,
onLeaveChannel,
}: {
browseAriaLabel?: string;
createAriaLabel: string;
browseLabel?: string;
createLabel?: string;
draggable?: boolean;
groupClassName?: string;
isCollapsed: boolean;
isActiveChannel: boolean;
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
items: Channel[];
leadingHeaderAction?: React.ReactNode;
listTestId: string;
onBrowseClick?: () => void;
onCreateClick?: () => void;
showQuickCreate?: boolean;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
@@ -373,6 +381,9 @@ export function ChannelGroupSection({
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
sortMode?: ChannelSortMode;
onSortModeChange?: (mode: ChannelSortMode) => void;
actionsTestId?: string;
title: string;
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
@@ -462,36 +473,37 @@ export function ChannelGroupSection({
<SidebarGroup
className={cn("group/sidebar-section select-none", groupClassName)}
>
<div className="relative">
<SidebarGroupLabel asChild>
<button
aria-controls={contentId}
aria-expanded={!isCollapsed}
className={SECTION_LABEL_BUTTON_CLASS}
onClick={onToggleCollapsed}
type="button"
>
<span>{title}</span>
<span aria-hidden="true" className={SECTION_LABEL_CHEVRON_CLASS}>
<ChevronDown
className={cn(
SECTION_LABEL_CHEVRON_ICON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
<ChannelSectionHeader
contentId={contentId}
isCollapsed={isCollapsed}
onToggleCollapsed={onToggleCollapsed}
title={title}
actions={
<>
{showQuickCreate && onCreateClick ? (
<SectionQuickAction
label={createLabel ?? "Create channel"}
onClick={onCreateClick}
testId={
actionsTestId ? `${actionsTestId}-quick-create` : undefined
}
/>
</span>
</button>
</SidebarGroupLabel>
<SectionHeaderActions
browseAriaLabel={browseAriaLabel}
createAriaLabel={createAriaLabel}
hasUnread={hasUnread}
leadingAction={leadingHeaderAction}
onBrowseClick={onBrowseClick}
onCreateClick={onCreateClick}
onMarkAllRead={onMarkAllRead}
/>
</div>
) : null}
<SectionActionsMenu
sectionLabel={title}
testId={actionsTestId}
hasUnread={hasUnread}
onMarkAllRead={onMarkAllRead}
onBrowse={onBrowseClick}
browseLabel={browseLabel}
onCreate={onCreateClick}
createLabel={createLabel}
sortMode={sortMode}
onSortModeChange={onSortModeChange}
/>
</>
}
/>
{!isCollapsed ? (
<SidebarGroupContent id={contentId}>{channelList}</SidebarGroupContent>
) : null}
@@ -519,7 +531,8 @@ export function CustomChannelSection({
assignments,
isFirst,
isLast,
leadingHeaderAction,
sortMode,
onSortModeChange,
onToggleCollapsed,
onSelectChannel,
onMarkChannelRead,
@@ -553,7 +566,8 @@ export function CustomChannelSection({
assignments: Record<string, string>;
isFirst: boolean;
isLast: boolean;
leadingHeaderAction?: React.ReactNode;
sortMode?: ChannelSortMode;
onSortModeChange?: (mode: ChannelSortMode) => void;
onToggleCollapsed: () => void;
onSelectChannel: (channelId: string) => void;
onMarkChannelRead: (
@@ -603,13 +617,6 @@ export function CustomChannelSection({
onClick={onToggleCollapsed}
type="button"
>
<GripVertical
className={cn(
"h-4 w-4 shrink-0 text-sidebar-foreground/30",
SECTION_ACTION_VISIBILITY_CLASS,
)}
aria-hidden="true"
/>
{section.icon ? (
<span
aria-hidden="true"
@@ -628,58 +635,34 @@ export function CustomChannelSection({
>
{section.name}
</span>
<ChevronDown
<span
aria-hidden="true"
className={cn(
SECTION_LABEL_CHEVRON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
className={SECTION_LABEL_CHEVRON_CLASS}
>
<ChevronDown
className={cn(
SECTION_LABEL_CHEVRON_ICON_CLASS,
isCollapsed ? "-rotate-90" : "rotate-0",
)}
/>
</span>
</button>
</SidebarGroupLabel>
<div
className={cn(
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
SECTION_ACTION_VISIBILITY_CLASS,
)}
>
{leadingHeaderAction}
{hasUnread ? (
<button
aria-label="Mark all as read"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onMarkSectionRead();
}}
title="Mark all as read"
type="button"
>
<CheckCheck className="h-4 w-4" />
</button>
) : null}
<button
aria-label="Rename section"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onRenameSection();
}}
type="button"
>
<Pencil className="h-4 w-4" />
</button>
<button
aria-label="Delete section"
className={SECTION_ICON_BUTTON_CLASS}
onClick={(e) => {
e.stopPropagation();
onDeleteSection();
}}
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<SectionActionsMenu
sectionLabel={section.name}
testId={`section-actions-${section.id}`}
hasUnread={hasUnread}
onMarkAllRead={onMarkSectionRead}
onRenameSection={onRenameSection}
onMoveSectionUp={onMoveSectionUp}
onMoveSectionDown={onMoveSectionDown}
onDeleteSection={onDeleteSection}
isFirstSection={isFirst}
isLastSection={isLast}
sortMode={sortMode}
onSortModeChange={onSortModeChange}
/>
</div>
</div>
</ContextMenuTrigger>

View File

@@ -15,7 +15,7 @@ import {
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { ChannelContextMenuItems } from "@/features/sidebar/ui/CustomChannelSection";
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";

View File

@@ -0,0 +1,35 @@
import type * as React from "react";
const SIDEBAR_CONTEXT_ICON_SLOT_CLASS =
"flex h-4 w-4 shrink-0 items-center justify-center";
/**
* Run a menu action on the next tick. Radix closes the menu on select and
* restores focus; deferring the action avoids opening a modal dialog while
* the menu is still tearing down (which can leave `pointer-events: none`
* stuck on <body> and freeze the app).
*/
export function deferMenuAction(action: () => void) {
globalThis.setTimeout(action, 0);
}
/**
* A fixed-width leading slot for context-menu rows so that labels stay
* left-aligned whether or not a given row has an icon. Pass no children for
* an intentionally blank (but still aligned) slot.
*/
export function ContextMenuIconSlot({
children,
}: {
children?: React.ReactNode;
}) {
return (
<span
aria-hidden="true"
className={SIDEBAR_CONTEXT_ICON_SLOT_CLASS}
data-sidebar-context-icon-slot
>
{children}
</span>
);
}

View File

@@ -66,11 +66,13 @@ test.describe("per-group channel sort", () => {
const streamList = page.getByTestId("stream-list");
await expect(streamList).toBeVisible();
await page.getByText("Channels", { exact: true }).hover();
const trigger = page.getByTestId("channel-sort-trigger-channels");
const trigger = page.getByTestId("section-actions-channels");
await expect(trigger).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-channels-sort-ingress.png` });
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "Recent" }),
).toBeVisible();
@@ -153,9 +155,11 @@ test.describe("per-group channel sort", () => {
const dmList = page.getByTestId("dm-list");
await expect(dmList).toBeVisible();
await page.getByText("Direct messages", { exact: true }).hover();
const trigger = page.getByTestId("channel-sort-trigger-dms");
const trigger = page.getByTestId("section-actions-dms");
await expect(trigger).toBeVisible();
await trigger.click();
// Sort is now a submenu flyout — open it before the radio items render.
await page.getByRole("menuitem", { name: "Sort" }).click();
await expect(
page.getByRole("menuitemradio", { name: "A–Z" }),
).toBeVisible();

View File

@@ -5,6 +5,8 @@ import {
TEST_IDENTITIES,
installMockBridge,
openChannelBrowser,
openCreateChannelDialog,
openNewDirectMessageDialog,
} from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
@@ -430,7 +432,7 @@ test("shows presence in sidebar, DM header, and member list", async ({
test("start a new direct message from the sidebar", async ({ page }) => {
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await openNewDirectMessageDialog(page);
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
await page.getByTestId("new-dm-search").fill("charlie");
@@ -464,7 +466,7 @@ test("start a new direct message from the sidebar", async ({ page }) => {
await expect(page.getByTestId("dm-list")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
await expect(page.getByTestId("new-dm-trigger")).not.toBeFocused();
await expect(page.getByTestId("section-actions-dms")).not.toBeFocused();
});
test("keeps direct message row add buttons hidden while opening", async ({
@@ -480,7 +482,7 @@ test("keeps direct message row add buttons hidden while opening", async ({
testWindow.__BUZZ_E2E__.mock.openDmDelayMs = 1_000;
});
await page.getByTestId("new-dm-trigger").click();
await openNewDirectMessageDialog(page);
await page.getByTestId("new-dm-search").fill("charlie");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
@@ -507,7 +509,7 @@ test("shows capped participant stack in group direct message header", async ({
}) => {
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await openNewDirectMessageDialog(page);
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
for (const identity of [
@@ -591,7 +593,7 @@ test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
@@ -608,7 +610,7 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
const channelName = `ephemeral-stream-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
@@ -650,7 +652,7 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
await page.goto("/");
for (const channelName of [firstChannelName, secondChannelName]) {
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
@@ -756,7 +758,7 @@ test("create stream with special characters", async ({ page }) => {
const channelName = `dev ops-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")
@@ -845,7 +847,7 @@ test("short channel with messages shows intro actions on open", async ({
const message = "Only message in a short channel";
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -881,7 +883,7 @@ test("scrollable channel with recent messages hides intro actions until top", as
);
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -1875,7 +1877,7 @@ test("new DM picker pages people search beyond the first 50 results", async ({
await installMockBridge(page, { searchProfiles });
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await openNewDirectMessageDialog(page);
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
await page.getByTestId("new-dm-search").fill("Alex");
@@ -2141,7 +2143,7 @@ test("removing a multi-channel managed bot preserves its record after removal fr
await page.goto("/");
const agentPubkey = await addGenericAgent(page, "general", agentName);
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(secondChannelName);
await page
.getByTestId("create-channel-description")
@@ -2325,7 +2327,7 @@ test("manage channel can delete an owned stream", async ({ page }) => {
const channelName = `delete-me-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
@@ -2346,7 +2348,7 @@ test("canceling channel deletion keeps the owned stream", async ({ page }) => {
const channelName = `keep-me-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);

View File

@@ -1,6 +1,9 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import {
installMockBridge,
openNewDirectMessageDialog,
} from "../helpers/bridge";
// Guards the NIP-IA discovery-suppression contract (Dawn's table v2):
// - Members sidebar: archived members fold under "Archived (N)", not in the
@@ -89,7 +92,7 @@ test.describe("NIP-IA hide archived from discovery", () => {
}) => {
await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] });
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await openNewDirectMessageDialog(page);
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
await page.getByTestId("new-dm-search").fill("alice");

View File

@@ -3,6 +3,7 @@ import { expect, test, type Browser } from "@playwright/test";
import {
installRelayBridge,
openChannelBrowser,
openCreateChannelDialog,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
@@ -16,7 +17,7 @@ async function createStream(
channelName: string,
description?: string,
) {
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
if (description !== undefined) {
await page.getByTestId("create-channel-description").fill(description);
@@ -182,7 +183,7 @@ test("create channel and verify in sidebar", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page.getByTestId("create-channel-submit").click();
@@ -206,7 +207,7 @@ test("two users see the same channel", async ({
await installRelayBridge(pageTwo, "alice");
await pageOne.goto("/");
await pageOne.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(pageOne);
await pageOne.getByTestId("create-channel-name").fill(channelName);
await pageOne.getByTestId("create-channel-submit").click();
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
@@ -428,12 +429,12 @@ test("multiple channels independent", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelA);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelB);
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText(channelB);

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge";
const DEFAULT_AGENT_ACTIVITY_PUBKEY =
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36";
@@ -100,7 +100,7 @@ test("creates a new mocked stream", async ({ page }) => {
const channelName = `release-notes-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")

View File

@@ -3,6 +3,7 @@ import { expect, test, type Browser, type Page } from "@playwright/test";
import {
installRelayBridge,
openChannelBrowser,
openCreateChannelDialog,
TEST_IDENTITIES,
} from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
@@ -59,7 +60,7 @@ async function createAndJoinSharedStream(
memberPage: Page,
channelName: string,
) {
await ownerPage.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(ownerPage);
await ownerPage.getByTestId("create-channel-name").fill(channelName);
await ownerPage.getByTestId("create-channel-submit").click();
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
@@ -248,7 +249,7 @@ test("creates a relay-backed stream", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await page.getByRole("button", { name: "Create a channel" }).click();
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill(channelName);
await page
.getByTestId("create-channel-description")

View File

@@ -559,3 +559,22 @@ export async function openChannelBrowser(page: Page) {
);
}, isMacBrowser);
}
// Section header actions (create channel, new DM, mark all read, sort) now
// live inside a per-section "more actions" (⋮) menu instead of standalone
// header icon buttons. These helpers open that menu and pick an item.
async function openSectionMenu(page: Page, actionsTestId: string) {
const trigger = page.getByTestId(actionsTestId);
await trigger.scrollIntoViewIfNeeded();
await trigger.click();
}
export async function openCreateChannelDialog(page: Page) {
await openSectionMenu(page, "section-actions-channels");
await page.getByRole("menuitem", { name: "New channel" }).click();
}
export async function openNewDirectMessageDialog(page: Page) {
await openSectionMenu(page, "section-actions-dms");
await page.getByRole("menuitem", { name: "New message" }).click();
}