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
79 changes: 51 additions & 28 deletions client/src/components/Nav/AgentMarketplaceButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@ import { Link } from 'react-router-dom';
import { LayoutGrid } from 'lucide-react';
import { Button, TooltipAnchor } from '@librechat/client';
import { useLocalize, useShowMarketplace } from '~/hooks';
import { cn } from '~/utils';

interface AgentMarketplaceButtonProps {
/** Which way the tooltip opens: the desktop rail is a left edge, the mobile
* drawer header is a top edge. */
* drawer header is a top edge. Ignored by the `row` layout, which has no
* tooltip to place. */
side?: 'right' | 'bottom';
/** Mobile dismisses the drawer on navigation; the desktop rail stays put. */
onNavigate?: () => void;
/**
* `icon` is the rail's square, labelled by a tooltip. `row` is a full-width
* entry carrying its own label, for the mobile drawer: an icon in a header
* strip is the easiest thing there to miss, and the list it now heads is
* where someone already looks for somewhere to go.
*/
layout?: 'icon' | 'row';
}

/** Agent Marketplace entry in the sidebar. Self-gated on marketplace
* permissions, so a deployment without access is left with no gap. */
export default function AgentMarketplaceButton({
side = 'right',
onNavigate,
layout = 'icon',
}: AgentMarketplaceButtonProps) {
const localize = useLocalize();
const showAgentMarketplace = useShowMarketplace();
Expand All @@ -24,33 +34,46 @@ export default function AgentMarketplaceButton({
return null;
}

const isRow = layout === 'row';

/** Composed through the shared button so the focus ring, hover fill and theme
* timing come from the primitive rather than being restated: a hand-rolled
* version of this had no focus ring at all. `asChild` keeps it a router Link,
* so modifier- and middle-clicks still open the marketplace in a new tab, and
* those stay on the current page and so must not dismiss. */
const button = (
<Button
asChild
variant="ghost"
size={isRow ? 'row' : 'icon'}
className={cn('shrink-0', isRow ? 'w-full justify-start' : 'h-9 w-9')}
>
<Link
to="/agents"
data-testid="nav-agents-marketplace-button"
aria-label={isRow ? undefined : localize('com_agents_marketplace')}
onClick={(event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
onNavigate?.();
}}
>
<LayoutGrid className="text-text-primary h-5 w-5 shrink-0" aria-hidden="true" />
{isRow && (
<span className="text-text-primary min-w-0 truncate text-sm font-medium">
{localize('com_agents_marketplace')}
</span>
)}
</Link>
</Button>
);

if (isRow) {
return button;
}

return (
<TooltipAnchor
side={side}
description={localize('com_agents_marketplace')}
render={
/** Composed through the shared button so the focus ring, hover fill and
* theme timing come from the primitive rather than being restated: a
* hand-rolled version of this had no focus ring at all. `asChild`
* keeps it a router Link, so modifier- and middle-clicks still open
* the marketplace in a new tab, and those stay on the current page and
* so must not dismiss. */
<Button asChild variant="ghost" size="icon" className="h-9 w-9 shrink-0">
<Link
to="/agents"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_agents_marketplace')}
onClick={(event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
onNavigate?.();
}}
>
<LayoutGrid className="text-text-primary h-5 w-5" aria-hidden="true" />
</Link>
</Button>
}
/>
<TooltipAnchor side={side} description={localize('com_agents_marketplace')} render={button} />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen, cleanup } from '@testing-library/react';

let mockShowMarketplace = true;

jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useShowMarketplace: () => mockShowMarketplace,
}));

jest.mock('@librechat/client', () => ({
Button: ({
children,
asChild: _asChild,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) => (
<button {...props}>{children}</button>
),
TooltipAnchor: ({ render: trigger }: { render: React.ReactNode }) => trigger,
}));

import AgentMarketplaceButton from '../AgentMarketplaceButton';

const renderButton = (layout?: 'icon' | 'row') =>
render(<AgentMarketplaceButton layout={layout} />, { wrapper: MemoryRouter });

describe('AgentMarketplaceButton', () => {
afterEach(() => {
mockShowMarketplace = true;
cleanup();
});

it('routes to the marketplace', () => {
renderButton();

expect(screen.getByTestId('nav-agents-marketplace-button')).toHaveAttribute('href', '/agents');
});

/** The drawer is the only sidebar surface on small screens, so this row is the
* one route to `/agents` there. */
it('shows its own label as a row, and leans on the tooltip as an icon', () => {
renderButton('row');
expect(screen.getByText('com_agents_marketplace')).toBeInTheDocument();
expect(screen.getByTestId('nav-agents-marketplace-button')).not.toHaveAttribute('aria-label');

cleanup();

renderButton('icon');
expect(screen.queryByText('com_agents_marketplace')).not.toBeInTheDocument();
expect(screen.getByTestId('nav-agents-marketplace-button')).toHaveAttribute(
'aria-label',
'com_agents_marketplace',
);
});

/** A deployment without marketplace access is left with no gap, in either layout. */
it('omits itself without marketplace access', () => {
mockShowMarketplace = false;

for (const layout of ['icon', 'row'] as const) {
const { container } = renderButton(layout);
expect(container).toBeEmptyDOMElement();
cleanup();
}
});
});
19 changes: 16 additions & 3 deletions client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { filesDialogTriggerAtom, showFilesDialogAtom } from '~/store/filesDialog';
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import useUnifiedSidebarLinks from '~/hooks/Nav/useUnifiedSidebarLinks';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
Expand Down Expand Up @@ -194,9 +195,13 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
id={MOBILE_DRAWER_ID}
className={cn(
/** The close swipe reads horizontal touches here (the drawer holds no
* horizontal scrollers), while pinch-zoom stays with the browser —
* horizontal scrollers), while pinch-zoom stays with the browser:
* this full-viewport surface must not disable zooming entirely. */
'bg-surface-primary-alt fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col',
'bg-surface-primary-alt text-text-primary fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col',
/** In dark mode the scrim and the drawer are both near-black, and no
* scrim opacity separates them by 3:1, so the drawer draws its own
* edge. Light palettes get that separation from the scrim. */
'dark:border-border-xheavy dark:border-r',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{
Expand All @@ -221,9 +226,17 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
links={links}
expanded={expanded}
onClose={handleCollapse}
onNewChat={handleCollapse}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
{/* Above the panel rather than inside it: the marketplace is a
destination like the panels themselves, not a row of whichever
list happens to be showing, so it stays put while they change and
does not scroll away with the chats. */}
<div className="shrink-0 px-3 pt-2 empty:hidden">
<AgentMarketplaceButton layout="row" onNavigate={handleCollapse} />
</div>
<nav
id="chat-history-nav"
className="bg-surface-primary-alt min-h-0 flex-1 overflow-hidden"
Expand All @@ -235,7 +248,7 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
<MobileBottomBar links={links} onNewChat={handleCollapse} />
<MobileBottomBar links={links} />
</ActivePanelProvider>
</SidebarChatProvider>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jest.mock('~/hooks/Nav/useUnifiedSidebarLinks', () => ({
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useChatHelpers: () => ({}),
useShowMarketplace: () => false,
}));

jest.mock('~/Providers', () => ({
Expand Down
84 changes: 18 additions & 66 deletions client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
Original file line number Diff line number Diff line change
@@ -1,90 +1,42 @@
import { memo, useCallback } from 'react';
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { SquarePen } from 'lucide-react';
import { Button } from '@librechat/client';
import type { NavLink } from '~/common';
import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers';
import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
import SearchBar from '~/components/Nav/SearchBar';
import useNewChat from '~/hooks/Chat/useNewChat';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';

/**
* Search and new chat are the two most frequent actions in the drawer, and both
* sat in the top corner — the hardest place to reach one-handed. A flex footer
* rather than an overlay, so the virtualized list shrinks around it and can
* never be occluded.
* Search, in reach of a thumb. A flex footer rather than an overlay, so the
* virtualized list shrinks around it and can never be occluded.
*
* New chat used to sit here too and is now in the header strip: repeated under
* every panel it was a second, larger copy of a destination that has nothing to
* do with prompts, memories or MCP settings.
*/
function BottomBar({
links,
onNewChat,
}: {
links: NavLink[];
onNewChat: (afterSlide?: () => void) => void;
}) {
const localize = useLocalize();
function BottomBar({ links }: { links: NavLink[] }) {
const search = useRecoilValue(store.search);
const switchToHistory = useRecoilValue(store.newChatSwitchToHistory);
const { active, setActive } = useActivePanel();

const { startNewChat } = useNewChat();

/**
* Close first, reset second — inverted from `useNewChat`'s own click
* handler: `startNewChat` clears the message cache and resets the
* conversation, and run synchronously that commit stalls the drawer
* slide's first frame on large conversations. The reset (and the panel
* switch-back) ride the close's `afterSlide` instead, landing mid-slide.
* The modified-click guard mirrors `useNewChat.handleNewChatClick` so a
* ctrl/middle click still opens `/c/new` in a new tab.
*/
const handleNewChatClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
onNewChat(() => {
if (switchToHistory) {
setActive(DEFAULT_PANEL);
}
startNewChat();
});
},
[onNewChat, switchToHistory, setActive, startNewChat],
);
/** The shortcut fires globally; assistive tech needs it discoverable here too. */
const newChatAriaKey = useShortcutAriaKey('newChat');
const { active } = useActivePanel();

/** Searching messages only means anything from the conversation list. */
const showSearch = search.enabled === true && resolveActivePanel(active, links) === DEFAULT_PANEL;

/** Without search the footer shrinks to the bottom safe-area inset alone, so
* the panel content still stops above the home indicator. */
return (
<div
className="flex shrink-0 items-center gap-2 px-3 pt-2"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))' }}
className={cn('flex shrink-0 items-center', showSearch && 'px-3 pt-2')}
style={{
paddingBottom: showSearch
? 'calc(0.75rem + env(safe-area-inset-bottom, 0px))'
: 'env(safe-area-inset-bottom, 0px)',
}}
>
{showSearch && (
<div className="min-w-0 flex-1">
<SearchBar isSmallScreen={true} />
</div>
)}
<Button
asChild
className={showSearch ? 'h-11 shrink-0 gap-2 rounded-full px-5' : 'h-11 w-full gap-2'}
>
<a
href="/c/new"
data-testid="nav-new-chat-fab"
aria-label={localize('com_ui_new_chat')}
aria-keyshortcuts={newChatAriaKey}
onClick={handleNewChatClick}
>
<SquarePen className="size-5" aria-hidden="true" />
<span className="text-sm font-medium">{localize('com_ui_new_chat')}</span>
</a>
</Button>
</div>
);
}
Expand Down
6 changes: 4 additions & 2 deletions client/src/components/UnifiedSidebar/mobile/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { memo, lazy, Suspense, useEffect, useRef } from 'react';
import { Button, Sidebar, Skeleton } from '@librechat/client';
import type { NavLink } from '~/common';
import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import Switcher from './Switcher';
import NewChat from './NewChat';

const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings'));

Expand All @@ -21,12 +21,14 @@ function Header({
links,
expanded,
onClose,
onNewChat,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
expanded: boolean;
onClose: () => void;
onNewChat: (afterSlide?: () => void) => void;
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
Expand Down Expand Up @@ -75,7 +77,7 @@ function Header({
onNavigate={onClose}
routeActiveId={routeActiveId}
/>
<AgentMarketplaceButton side="bottom" onNavigate={onClose} />
<NewChat onNewChat={onNewChat} />
<Suspense fallback={<Skeleton className="size-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>
Expand Down
Loading
Loading