Collapse channel header actions (#901)

This commit is contained in:
klopez4212
2026-06-08 17:18:11 +01:00
committed by GitHub
parent 4ec7f8125e
commit ecca5e77e4
10 changed files with 250 additions and 63 deletions

View File

@@ -177,6 +177,7 @@ export function AppShell() {
const [isChannelManagementOpen, setIsChannelManagementOpen] = const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false); React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
const [browseDialogType, setBrowseDialogType] = const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null); React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
@@ -675,6 +676,7 @@ export function AppShell() {
unfollowThread: handleUnfollowThread, unfollowThread: handleUnfollowThread,
isFollowingThread, isFollowingThread,
isNotifiedForThread, isNotifiedForThread,
setTopbarSearchHidden,
threadActivityItems, threadActivityItems,
}} }}
> >
@@ -693,6 +695,7 @@ export function AppShell() {
void goChannel(channelId); void goChannel(channelId);
}} }}
onOpenResult={handleOpenSearchResult} onOpenResult={handleOpenSearchResult}
searchHidden={topbarSearchHidden}
searchFocusRequest={searchFocusRequest} searchFocusRequest={searchFocusRequest}
/> />
) : null} ) : null}

View File

@@ -20,6 +20,7 @@ type AppShellContextValue = {
unfollowThread: (rootId: string) => void; unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean; isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean; isNotifiedForThread: (rootId: string) => boolean;
setTopbarSearchHidden: (hidden: boolean) => void;
threadActivityItems: ThreadActivityItem[]; threadActivityItems: ThreadActivityItem[];
}; };
@@ -34,6 +35,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
unfollowThread: () => {}, unfollowThread: () => {},
isFollowingThread: () => false, isFollowingThread: () => false,
isNotifiedForThread: () => false, isNotifiedForThread: () => false,
setTopbarSearchHidden: () => {},
threadActivityItems: [], threadActivityItems: [],
}); });

View File

@@ -14,6 +14,7 @@ type AppTopChromeProps = {
onGoForward: () => void; onGoForward: () => void;
onOpenChannel: (channelId: string) => void; onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void; onOpenResult: (hit: SearchHit) => void;
searchHidden?: boolean;
searchFocusRequest: number; searchFocusRequest: number;
}; };
@@ -41,6 +42,7 @@ export function AppTopChrome({
onGoForward, onGoForward,
onOpenChannel, onOpenChannel,
onOpenResult, onOpenResult,
searchHidden = false,
searchFocusRequest, searchFocusRequest,
}: AppTopChromeProps) { }: AppTopChromeProps) {
return ( return (
@@ -76,14 +78,16 @@ export function AppTopChrome({
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
<TopbarSearch {searchHidden ? null : (
channels={channels} <TopbarSearch
className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]" channels={channels}
currentPubkey={currentPubkey} className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
focusRequest={searchFocusRequest} currentPubkey={currentPubkey}
onOpenChannel={onOpenChannel} focusRequest={searchFocusRequest}
onOpenResult={onOpenResult} onOpenChannel={onOpenChannel}
/> onOpenResult={onOpenResult}
/>
)}
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Plus, Settings2, Users } from "lucide-react"; import { EllipsisVertical, Plus, Settings2, Users } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useHuddle } from "@/features/huddle"; import { useHuddle } from "@/features/huddle";
@@ -13,6 +13,12 @@ import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { Channel } from "@/shared/api/types"; import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey"; import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { AddChannelBotDialog } from "./AddChannelBotDialog"; import { AddChannelBotDialog } from "./AddChannelBotDialog";
type ChannelMembersBarProps = { type ChannelMembersBarProps = {
@@ -20,6 +26,7 @@ type ChannelMembersBarProps = {
currentPubkey?: string; currentPubkey?: string;
onManageChannel: () => void; onManageChannel: () => void;
onToggleMembers: () => void; onToggleMembers: () => void;
variant?: "inline" | "compact";
}; };
export function ChannelMembersBar({ export function ChannelMembersBar({
@@ -27,6 +34,7 @@ export function ChannelMembersBar({
currentPubkey, currentPubkey,
onManageChannel, onManageChannel,
onToggleMembers, onToggleMembers,
variant = "inline",
}: ChannelMembersBarProps) { }: ChannelMembersBarProps) {
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
@@ -82,8 +90,71 @@ export function ChannelMembersBar({
? relayAgentsQuery.error.message ? relayAgentsQuery.error.message
: null; : null;
return ( const huddleIndicator = (
<React.Fragment> <HuddleIndicator
channelId={channel.id}
onStart={async () => {
try {
await startHuddle(channel.id, []);
// Refetch channels so the new ephemeral channel appears in the sidebar immediately
// (default poll interval is 60s — too slow for huddle UX).
void queryClient.invalidateQueries({ queryKey: ["channels"] });
} catch (e) {
console.error("Failed to start huddle:", e);
}
}}
renderMode={variant === "compact" ? "menu-item" : "button"}
startDisabled={!canAddAgents || isStartingHuddle}
/>
);
const controls =
variant === "compact" ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
className="h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="ghost"
>
<EllipsisVertical className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onSelect={() => {
setIsAddBotOpen(true);
}}
>
<Plus />
<span>Add agent</span>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
<Button <Button
aria-label="Add agent" aria-label="Add agent"
@@ -114,21 +185,7 @@ export function ChannelMembersBar({
</span> </span>
</Button> </Button>
<HuddleIndicator {huddleIndicator}
className="h-8 w-8"
channelId={channel.id}
onStart={async () => {
try {
await startHuddle(channel.id, []);
// Refetch channels so the new ephemeral channel appears in the sidebar immediately
// (default poll interval is 60s — too slow for huddle UX).
void queryClient.invalidateQueries({ queryKey: ["channels"] });
} catch (e) {
console.error("Failed to start huddle:", e);
}
}}
startDisabled={!canAddAgents || isStartingHuddle}
/>
<Button <Button
aria-label="Manage channel" aria-label="Manage channel"
@@ -142,6 +199,11 @@ export function ChannelMembersBar({
<Settings2 className="size-5" /> <Settings2 className="size-5" />
</Button> </Button>
</div> </div>
);
return (
<React.Fragment>
{controls}
<AddChannelBotDialog <AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []} backendProviders={backendProvidersQuery.data ?? []}

View File

@@ -45,7 +45,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { import {
useElementWidthBreakpoint, useElementWidth,
useIsThreadPanelOverlay, useIsThreadPanelOverlay,
} from "@/shared/hooks/use-mobile"; } from "@/shared/hooks/use-mobile";
import { import {
@@ -61,6 +61,10 @@ import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelRouteTarget } from "./useChannelRouteTarget";
import type { ChannelScreenProps } from "./ChannelScreen.types"; import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
const HEADER_ACTIONS_SPLIT_GUTTER_PX = 12;
export function ChannelScreen({ export function ChannelScreen({
activeChannel, activeChannel,
currentIdentity, currentIdentity,
@@ -80,6 +84,7 @@ export function ChannelScreen({
unfollowThread, unfollowThread,
isFollowingThread, isFollowingThread,
isNotifiedForThread, isNotifiedForThread,
setTopbarSearchHidden,
} = useAppShell(); } = useAppShell();
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null string | null
@@ -92,10 +97,8 @@ export function ChannelScreen({
} = useThreadPanelWidth(); } = useThreadPanelWidth();
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const isThreadPanelOverlay = useIsThreadPanelOverlay(); const isThreadPanelOverlay = useIsThreadPanelOverlay();
const [channelContentRef, isNarrowPanelViewport] = const [channelContentRef, channelContentWidthPx] =
useElementWidthBreakpoint<HTMLDivElement>( useElementWidth<HTMLDivElement>();
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
);
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>( const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null, null,
); );
@@ -434,21 +437,32 @@ export function ChannelScreen({
]); ]);
useLoadMissingAncestors(activeChannel, resolvedMessages); useLoadMissingAncestors(activeChannel, resolvedMessages);
const hasAuxiliaryPanel = Boolean(
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
);
const isNarrowPanelViewport =
channelContentWidthPx > 0 &&
channelContentWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const isSinglePanelView = const isSinglePanelView =
isNarrowPanelViewport && isNarrowPanelViewport &&
activeChannel?.channelType !== "forum" && activeChannel?.channelType !== "forum" &&
Boolean( hasAuxiliaryPanel;
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
);
const hasSplitRightPanel = const hasSplitRightPanel =
!isSinglePanelView && !isSinglePanelView && !isThreadPanelOverlay && hasAuxiliaryPanel;
!isThreadPanelOverlay && const shouldCompactHeaderActions =
Boolean( hasAuxiliaryPanel &&
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey, channelContentWidthPx > 0 &&
); channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX;
const headerActionsRightInset = hasSplitRightPanel const splitRightPanelInset = hasSplitRightPanel
? `min(${threadPanelWidthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))` ? `min(${threadPanelWidthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
: undefined; : undefined;
const headerActionsRightInset = splitRightPanelInset
? `calc(${splitRightPanelInset} + ${HEADER_ACTIONS_SPLIT_GUTTER_PX}px)`
: undefined;
React.useEffect(() => {
setTopbarSearchHidden(isSinglePanelView);
return () => setTopbarSearchHidden(false);
}, [isSinglePanelView, setTopbarSearchHidden]);
return ( return (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}> <AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
@@ -458,6 +472,7 @@ export function ChannelScreen({
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay} activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle} activeChannelTitle={activeChannelTitle}
actionsRightInset={headerActionsRightInset} actionsRightInset={headerActionsRightInset}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmPresenceStatus={activeDmPresenceStatus} activeDmPresenceStatus={activeDmPresenceStatus}
currentPubkey={currentPubkey} currentPubkey={currentPubkey}
isJoining={joinChannelMutation.isPending} isJoining={joinChannelMutation.isPending}

View File

@@ -1,12 +1,10 @@
import { LogIn } from "lucide-react"; import { LogIn } from "lucide-react";
import { createPortal } from "react-dom";
import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { getChannelDescription } from "@/features/channels/lib/channelDescription"; import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types"; import type { Channel, PresenceStatus } from "@/shared/api/types";
@@ -15,6 +13,7 @@ type ChannelScreenHeaderProps = {
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null; activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
activeChannelTitle: string; activeChannelTitle: string;
actionsRightInset?: string; actionsRightInset?: string;
actionsVariant?: "inline" | "compact";
activeDmPresenceStatus: PresenceStatus | null; activeDmPresenceStatus: PresenceStatus | null;
currentPubkey?: string; currentPubkey?: string;
isJoining?: boolean; isJoining?: boolean;
@@ -29,6 +28,7 @@ export function ChannelScreenHeader({
activeChannelEphemeralDisplay, activeChannelEphemeralDisplay,
activeChannelTitle, activeChannelTitle,
actionsRightInset, actionsRightInset,
actionsVariant = "inline",
activeDmPresenceStatus, activeDmPresenceStatus,
currentPubkey, currentPubkey,
isJoining = false, isJoining = false,
@@ -61,22 +61,13 @@ export function ChannelScreenHeader({
currentPubkey={currentPubkey} currentPubkey={currentPubkey}
onManageChannel={onManageChannel} onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers} onToggleMembers={onToggleMembers}
variant={actionsVariant}
/> />
) )
) : null; ) : null;
if (!showHeaderContent) { if (!showHeaderContent) {
if (typeof document === "undefined") { return null;
return null;
}
return createPortal(
<div className="fixed right-3 top-[9px] z-[45] flex shrink-0 items-center gap-1">
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>,
document.body,
);
} }
return ( return (

View File

@@ -136,16 +136,19 @@ export function ChatHeader({
typeof document === "undefined" ? null : ( typeof document === "undefined" ? null : (
createPortal(topRightActions, document.body) createPortal(topRightActions, document.body)
) )
) : ( ) : actionsRightInset ? (
<div <div
className="flex shrink-0 items-center gap-1" className="absolute top-1/2 z-10 flex shrink-0 -translate-y-1/2 items-center gap-1"
style={ style={{ right: actionsRightInset }}
actionsRightInset ? { marginRight: actionsRightInset } : undefined
}
> >
<UpdateIndicator /> <UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null} {actions ? <div className="shrink-0">{actions}</div> : null}
</div> </div>
) : (
<div className="flex shrink-0 items-center gap-1">
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
)} )}
</header> </header>
); );

View File

@@ -7,6 +7,7 @@ import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types"; import type { RelayEvent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { useHuddle } from "../HuddleContext"; import { useHuddle } from "../HuddleContext";
import { useHeadphonesGate } from "../lib/useHeadphonesGate"; import { useHeadphonesGate } from "../lib/useHeadphonesGate";
@@ -26,6 +27,7 @@ type ActiveHuddle = {
type HuddleIndicatorProps = { type HuddleIndicatorProps = {
channelId: string; channelId: string;
className?: string; className?: string;
renderMode?: "button" | "menu-item";
/** Called when the user clicks the button and no huddle is active (start). */ /** Called when the user clicks the button and no huddle is active (start). */
onStart?: () => void; onStart?: () => void;
/** Whether the start action is disabled (e.g., permissions, already starting). */ /** Whether the start action is disabled (e.g., permissions, already starting). */
@@ -40,6 +42,7 @@ type HuddleIndicatorProps = {
export function HuddleIndicator({ export function HuddleIndicator({
channelId, channelId,
className, className,
renderMode = "button",
onStart, onStart,
startDisabled, startDisabled,
}: HuddleIndicatorProps) { }: HuddleIndicatorProps) {
@@ -226,6 +229,23 @@ export function HuddleIndicator({
// No active huddle — render the start button (if onStart provided). // No active huddle — render the start button (if onStart provided).
if (!activeHuddle) { if (!activeHuddle) {
if (!onStart) return null; if (!onStart) return null;
if (renderMode === "menu-item") {
return (
<>
<DropdownMenuItem
className={className}
data-testid="channel-start-huddle-trigger"
disabled={startDisabled || isStarting}
onSelect={() => headphonesGate.gate(() => onStart())}
>
<Headphones />
<span>Start huddle</span>
</DropdownMenuItem>
{gateDialog}
</>
);
}
return ( return (
<> <>
<Button <Button
@@ -267,6 +287,26 @@ export function HuddleIndicator({
} }
} }
if (renderMode === "menu-item") {
return (
<>
<DropdownMenuItem
className={className}
data-testid="channel-start-huddle-trigger"
disabled={isJoining || isStarting}
onSelect={() => headphonesGate.gate(() => void doJoin())}
>
<Headphones />
<span>Join huddle</span>
<span className="ml-auto text-xs text-muted-foreground">
{participantCount}
</span>
</DropdownMenuItem>
{gateDialog}
</>
);
}
return ( return (
<> <>
<Tooltip> <Tooltip>

View File

@@ -11,11 +11,9 @@ const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
const NESTED_REPLY_OFFSET_PX = 28; const NESTED_REPLY_OFFSET_PX = 28;
function ParticipantAvatar({ function ParticipantAvatar({
hasNextParticipant,
participant, participant,
index, index,
}: { }: {
hasNextParticipant: boolean;
participant: TimelineThreadSummaryParticipant; participant: TimelineThreadSummaryParticipant;
index: number; index: number;
}) { }) {
@@ -27,7 +25,7 @@ function ParticipantAvatar({
> >
<UserAvatar <UserAvatar
avatarUrl={participant.avatarUrl} avatarUrl={participant.avatarUrl}
className={`h-8 w-8 text-[10px] ${hasNextParticipant ? "ring-2 ring-background" : ""}`} className="h-7 w-7 text-[10px] ring-2 ring-background"
displayName={participant.author} displayName={participant.author}
size="sm" size="sm"
/> />
@@ -100,7 +98,6 @@ export function MessageThreadSummaryRow({
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
{summary.participants.map((participant, index) => ( {summary.participants.map((participant, index) => (
<ParticipantAvatar <ParticipantAvatar
hasNextParticipant={index < summary.participants.length - 1}
index={index} index={index}
key={participant.id} key={participant.id}
participant={participant} participant={participant}

View File

@@ -486,7 +486,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return `${Math.round(rect.width)}x${Math.round(rect.height)}`; return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
}), }),
) )
.toBe("32x32"); .toBe("28x28");
await page.mouse.move(0, 0); await page.mouse.move(0, 0);
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) => const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
@@ -718,6 +718,76 @@ test("thread panel width uses session storage and reset handle", async ({
.toBe(defaultWidthPx); .toBe(defaultWidthPx);
}); });
test("narrow thread view collapses channel header actions into a menu", async ({
page,
}) => {
await page.setViewportSize({ width: 980, height: 720 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
const threadPanel = page.getByTestId("message-thread-panel");
await rootMessage.hover();
await page.getByTestId("reply-message-mock-general-alice").click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
const menuTrigger = page.getByTestId("channel-actions-menu-trigger");
await expect(menuTrigger).toBeVisible();
await expect(page.getByTestId("channel-add-bot-trigger")).toBeHidden();
await expect(page.getByTestId("channel-members-trigger")).toBeHidden();
await expect(page.getByTestId("channel-management-trigger")).toBeHidden();
const menuBox = await menuTrigger.boundingBox();
const threadPanelBox = await threadPanel.boundingBox();
if (!menuBox || !threadPanelBox) {
throw new Error("Expected header action menu and thread panel bounds");
}
const menuGapPx = threadPanelBox.x - (menuBox.x + menuBox.width);
expect(menuGapPx).toBeGreaterThanOrEqual(10);
expect(menuGapPx).toBeLessThanOrEqual(14);
await menuTrigger.click();
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
await expect(page.getByTestId("channel-members-trigger")).toBeVisible();
await expect(page.getByTestId("channel-start-huddle-trigger")).toBeVisible();
await expect(page.getByTestId("channel-management-trigger")).toBeVisible();
});
test("single-panel thread view hides topbar search and channel actions", async ({
page,
}) => {
await page.setViewportSize({ width: 860, height: 720 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("open-search")).toBeVisible();
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
const threadPanel = page.getByTestId("message-thread-panel");
await rootMessage.hover();
await page.getByTestId("reply-message-mock-general-alice").click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible();
await expect(page.getByTestId("open-search")).toHaveCount(0);
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
await threadPanel.getByTestId("message-thread-back").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("open-search")).toBeVisible();
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
});
test("composer is focused after selecting a channel", async ({ page }) => { test("composer is focused after selecting a channel", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.getByTestId("channel-general").click(); await page.getByTestId("channel-general").click();