Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -675,6 +676,7 @@ export function AppShell() {
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
setTopbarSearchHidden,
threadActivityItems,
}}
>
Expand All @@ -693,6 +695,7 @@ export function AppShell() {
void goChannel(channelId);
}}
onOpenResult={handleOpenSearchResult}
searchHidden={topbarSearchHidden}
searchFocusRequest={searchFocusRequest}
/>
) : null}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type AppShellContextValue = {
unfollowThread: (rootId: string) => void;
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
setTopbarSearchHidden: (hidden: boolean) => void;
threadActivityItems: ThreadActivityItem[];
};

Expand All @@ -34,6 +35,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
unfollowThread: () => {},
isFollowingThread: () => false,
isNotifiedForThread: () => false,
setTopbarSearchHidden: () => {},
threadActivityItems: [],
});

Expand Down
20 changes: 12 additions & 8 deletions desktop/src/app/AppTopChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type AppTopChromeProps = {
onGoForward: () => void;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
searchHidden?: boolean;
searchFocusRequest: number;
};

Expand Down Expand Up @@ -41,6 +42,7 @@ export function AppTopChrome({
onGoForward,
onOpenChannel,
onOpenResult,
searchHidden = false,
searchFocusRequest,
}: AppTopChromeProps) {
return (
Expand Down Expand Up @@ -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}
/>
)}
</>
);
}
98 changes: 80 additions & 18 deletions desktop/src/features/channels/ui/ChannelMembersBar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,20 +13,28 @@ 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 = {
channel: Channel;
currentPubkey?: string;
onManageChannel: () => void;
onToggleMembers: () => void;
variant?: "inline" | "compact";
};

export function ChannelMembersBar({
channel,
currentPubkey,
onManageChannel,
onToggleMembers,
variant = "inline",
}: ChannelMembersBarProps) {
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -142,6 +199,11 @@ export function ChannelMembersBar({
<Settings2 className="size-5" />
</Button>
</div>
);

return (
<React.Fragment>
{controls}

<AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []}
Expand Down
43 changes: 29 additions & 14 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -80,6 +84,7 @@ export function ChannelScreen({
unfollowThread,
isFollowingThread,
isNotifiedForThread,
setTopbarSearchHidden,
} = useAppShell();
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
Expand All @@ -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,
);
Expand Down Expand Up @@ -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}>
Expand All @@ -458,6 +472,7 @@ export function ChannelScreen({
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle}
actionsRightInset={headerActionsRightInset}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmPresenceStatus={activeDmPresenceStatus}
currentPubkey={currentPubkey}
isJoining={joinChannelMutation.isPending}
Expand Down
17 changes: 4 additions & 13 deletions desktop/src/features/channels/ui/ChannelScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,6 +13,7 @@ type ChannelScreenHeaderProps = {
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
activeChannelTitle: string;
actionsRightInset?: string;
actionsVariant?: "inline" | "compact";
activeDmPresenceStatus: PresenceStatus | null;
currentPubkey?: string;
isJoining?: boolean;
Expand All @@ -29,6 +28,7 @@ export function ChannelScreenHeader({
activeChannelEphemeralDisplay,
activeChannelTitle,
actionsRightInset,
actionsVariant = "inline",
activeDmPresenceStatus,
currentPubkey,
isJoining = false,
Expand Down Expand Up @@ -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 (
Expand Down
13 changes: 8 additions & 5 deletions desktop/src/features/chat/ui/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Comment on lines 140 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve space for inset header actions

When a split panel is open and the channel name/status is wide, this branch absolutely positions the actions at right: actionsRightInset but removes them from the header's flex flow, so the title block still lays out underneath the update/action buttons and can overlap them instead of truncating before them. The previous margin-right version reserved that space; please add equivalent right padding or flow spacing when using an inset.

Useful? React with 👍 / 👎.

>
<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>
);
Expand Down
Loading