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

View File

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

View File

@@ -14,6 +14,7 @@ type AppTopChromeProps = {
onGoForward: () => void;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
searchHidden?: boolean;
searchFocusRequest: number;
};
@@ -41,6 +42,7 @@ export function AppTopChrome({
onGoForward,
onOpenChannel,
onOpenResult,
searchHidden = false,
searchFocusRequest,
}: AppTopChromeProps) {
return (
@@ -76,14 +78,16 @@ export function AppTopChrome({
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<TopbarSearch
channels={channels}
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]"
currentPubkey={currentPubkey}
focusRequest={searchFocusRequest}
onOpenChannel={onOpenChannel}
onOpenResult={onOpenResult}
/>
{searchHidden ? null : (
<TopbarSearch
channels={channels}
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]"
currentPubkey={currentPubkey}
focusRequest={searchFocusRequest}
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 { useQueryClient } from "@tanstack/react-query";
import { useHuddle } from "@/features/huddle";
@@ -13,6 +13,12 @@ import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { AddChannelBotDialog } from "./AddChannelBotDialog";
type ChannelMembersBarProps = {
@@ -20,6 +26,7 @@ type ChannelMembersBarProps = {
currentPubkey?: string;
onManageChannel: () => void;
onToggleMembers: () => void;
variant?: "inline" | "compact";
};
export function ChannelMembersBar({
@@ -27,6 +34,7 @@ export function ChannelMembersBar({
currentPubkey,
onManageChannel,
onToggleMembers,
variant = "inline",
}: ChannelMembersBarProps) {
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
@@ -82,8 +90,71 @@ export function ChannelMembersBar({
? relayAgentsQuery.error.message
: null;
return (
<React.Fragment>
const huddleIndicator = (
<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]">
<Button
aria-label="Add agent"
@@ -114,21 +185,7 @@ export function ChannelMembersBar({
</span>
</Button>
<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}
/>
{huddleIndicator}
<Button
aria-label="Manage channel"
@@ -142,6 +199,11 @@ export function ChannelMembersBar({
<Settings2 className="size-5" />
</Button>
</div>
);
return (
<React.Fragment>
{controls}
<AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []}

View File

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

View File

@@ -1,12 +1,10 @@
import { LogIn } from "lucide-react";
import { createPortal } from "react-dom";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
@@ -15,6 +13,7 @@ type ChannelScreenHeaderProps = {
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
activeChannelTitle: string;
actionsRightInset?: string;
actionsVariant?: "inline" | "compact";
activeDmPresenceStatus: PresenceStatus | null;
currentPubkey?: string;
isJoining?: boolean;
@@ -29,6 +28,7 @@ export function ChannelScreenHeader({
activeChannelEphemeralDisplay,
activeChannelTitle,
actionsRightInset,
actionsVariant = "inline",
activeDmPresenceStatus,
currentPubkey,
isJoining = false,
@@ -61,22 +61,13 @@ export function ChannelScreenHeader({
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers}
variant={actionsVariant}
/>
)
) : null;
if (!showHeaderContent) {
if (typeof document === "undefined") {
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 null;
}
return (

View File

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

View File

@@ -7,6 +7,7 @@ import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { useHuddle } from "../HuddleContext";
import { useHeadphonesGate } from "../lib/useHeadphonesGate";
@@ -26,6 +27,7 @@ type ActiveHuddle = {
type HuddleIndicatorProps = {
channelId: string;
className?: string;
renderMode?: "button" | "menu-item";
/** Called when the user clicks the button and no huddle is active (start). */
onStart?: () => void;
/** Whether the start action is disabled (e.g., permissions, already starting). */
@@ -40,6 +42,7 @@ type HuddleIndicatorProps = {
export function HuddleIndicator({
channelId,
className,
renderMode = "button",
onStart,
startDisabled,
}: HuddleIndicatorProps) {
@@ -226,6 +229,23 @@ export function HuddleIndicator({
// No active huddle — render the start button (if onStart provided).
if (!activeHuddle) {
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 (
<>
<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 (
<>
<Tooltip>

View File

@@ -11,11 +11,9 @@ const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
const NESTED_REPLY_OFFSET_PX = 28;
function ParticipantAvatar({
hasNextParticipant,
participant,
index,
}: {
hasNextParticipant: boolean;
participant: TimelineThreadSummaryParticipant;
index: number;
}) {
@@ -27,7 +25,7 @@ function ParticipantAvatar({
>
<UserAvatar
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}
size="sm"
/>
@@ -100,7 +98,6 @@ export function MessageThreadSummaryRow({
<div className="flex shrink-0 items-center">
{summary.participants.map((participant, index) => (
<ParticipantAvatar
hasNextParticipant={index < summary.participants.length - 1}
index={index}
key={participant.id}
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)}`;
}),
)
.toBe("32x32");
.toBe("28x28");
await page.mouse.move(0, 0);
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
@@ -718,6 +718,76 @@ test("thread panel width uses session storage and reset handle", async ({
.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 }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();